I'm currently working on a project called Beacon. It's a code of conduct reporting and management system designed for use by open source projects of all sizes, from one-maintainer Ruby gems to enterprise open source initiatives. Because of the sensitive nature of the project, it will certainly be a target for malicious actors who want to use it to harass both reporters and project maintainers. So security and anti-harassment features are paramount as I work on making Beacon a reality.
One feature that is important to me has to do with the security of the data in the database. There are three primary data models related to this feature: projects, issues (reports of potential code of conduct violations), and accounts (users). The abstracted data model looks something like this:
In a typical Rails project, these relations would be straightforward:
class Project < ApplicationRecord
has_many :issues
end
class Issue < ApplicationRecord
belongs_to :project
belongs_to: account
end
class Account < ApplicationRecord
has_many :issues
end
This would put project_id
and account_id
attributes on the Issue model.
But in the case of Beacon, I wanted to make these relations more secure. In short, I wanted to make sure that if someone gained access to the database, they would not be able to associate issues with either reporters (accounts) or projects.
issue_encrypted_id
column.
class CreateProjectsIssues < ActiveRecord::Migration[5.2]
def change
create_table :project_issues, id: :uuid do |t|
t.references :project, type: :uuid, foreign_key: true
t.string :issue_encrypted_id, null: false
t.timestamps
end
end
end
Next we'll create a simple ActiveRecord class:
class ProjectIssue < ApplicationRecord
validates_uniqueness_of :issue_encrypted_id
belongs_to :project
end
We add the relation to our Project
model:
class Project < ApplicationRecord
has_many :project_issues
...
end
To prevent the unencrypted ID from leaking through the logs, open up config/initializers/filter_parameter_logging.rb
and add the issue_id:
Rails.application.config.filter_parameters += [
:password,
:issue_id,
:account_id,
:project_id
]
Now, when we create an issue for a project, we have some work to do.
class Issue < ApplicationRecord
attr_accessor :project_id
after_create :set_project_encrypted_id
def project
@project ||= Project.find(
EncryptionService.decrypt(self.project_encrypted_id)
)
end
private
def set_project_encrypted_id
update_attribute(
:project_encrypted_id,
EncryptionService.encrypt(self.project_id)
)
ProjectIssue.create(project_id: self.project_id, issue_id: self.id)
end
end
We add an attr_accessor
to temporarily store the project_id
. In an after_create
block, we encrypt the project ID and create a ProjectIssue
record.
We also added a project
method so that the issue can retrieve its associated project.
Now, ProjectIssue
is responsible for encrypting the issue ID:
class ProjectIssue < ApplicationRecord
validates_uniqueness_of :issue_encrypted_id
before_create :encrypt_issue_id
attr_accessor :issue_id
belongs_to :project
...
private
def encrypt_issue_id
self.issue_encrypted_id = EncryptionService.encrypt(issue_id)
end
end
You may have noticed the calls to EncryptionService
. This is a straightforward class that uses the Rails application's secret key to encrypt and decrypt the UUIDs that serve as the primary keys for all of our records.
In app/services/encryption_service.rb
:
class EncryptionService
def self.encrypt(text)
len = ActiveSupport::MessageEncryptor.key_len
salt = SecureRandom.hex len
key = ActiveSupport::KeyGenerator.new(
Rails.application.credentials.secret_key_base
).generate_key(salt, len)
crypt = ActiveSupport::MessageEncryptor.new key
encrypted_data = crypt.encrypt_and_sign(text)
"#{salt}$$#{encrypted_data}"
end
def self.decrypt(text)
salt, data = text.split("$$")
len = ActiveSupport::MessageEncryptor.key_len
key = ActiveSupport::KeyGenerator.new(
Rails.application.credentials.secret_key_base
).generate_key(salt, len)
crypt = ActiveSupport::MessageEncryptor.new(key)
crypt.decrypt_and_verify(data)
end
end
ProjectIssue
relation. Since we don't have the syntactic sugar of an ActiveRecord relation, we build our own method to access the associated issues:
class Project < ApplicationRecord
...
def issues
@issues ||= ProjectIssue.issues_for_project(self.id)
end
...
end
Then we add the finder to ProjectIssue
:
class ProjectIssue < ApplicationRecord
...
def self.issues_for_project(project_id)
encrypted_issue_ids = where(project_id: project_id)
.pluck(:issue_encrypted_id)
issue_ids = encrypted_issue_ids.map{ |id| EncryptionService.decrypt(id) }
Issue.where(id: issue_ids)
end
...
end
That's it! Now, with a project in hand, we can call my_project.issues
and get an array of associated issues back.