Coraline Ada Ehmke

Encrypting Relations in Rails

Coraline Ada Ehmke | January 13, 2019

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.

The Data Model

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.

Encrypting Relations

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

Decrypting Relations

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.

Wrapping Up

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!