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.
The solution to this is to store a project’s issue IDs as an encrypted string in a lookup table in the database. We start with a migration. Notice the 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
To retrieve the issues for a given project, we need to go through the 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.
I’m considering building this functionality into a gem, but it’s not likely that I’ll have time to get around to this any time soon. So to any prospective gem authors out there, have at it!
If you liked this post and want to share feedback, please feel free to reach out either through my contact form or via Twitter (where I’m @CoralineAda).
Now go out there and secure some relations!