44
loading...
This website collects cookies to deliver better user experience
# app/models/concerns/account_ownable.rb
module AccountOwnable
extend ActiveSupport::Concern
included do
# Account is actually not optional, but we not do want
# to generate a SELECT query to verify the account is
# there every time. We get this protection for free
# because of the `Current.account_or_raise!`
# and also through FK constraints.
belongs_to :account, optional: true
default_scope { where(account: Current.account_or_raise!) }
end
end
# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
attribute :account, :user
resets { Time.zone = nil }
class MissingCurrentAccount < StandardError; end
def account_or_raise!
raise Current::MissingCurrentAccount, "You must set an account with Current.account=" unless account
account
end
def user=(user)
super
self.account = user.account
Time.zone = user.time_zone
end
end
class ApiKey < ApplicationRecord
# assumes table has a column named `account_id`
include AccountOwnable
end
Current.user
in our authentication controller concern which is mixed into our ApplicationController
# app/controllers/concerns/require_authentication.rb
module RequireAuthentication
extend ActiveSupport::Concern
included do
before_action :ensure_authenticated_user
end
def ensure_authenticated_user
if (user = User.find_by_valid_session(session))
Current.user = user
else
redirect_to signin_path
end
end
end
Because of the default_scope
, once a user is signed in, data from sensitive models is automatically scoped to their account. We just don't need to think about it, no matter how complicated our query chaining gets.
Again, because of the default_scope
creating new records for these AccountOwnable
models will automatically set the account_id
for us. One less thing to think about.
In situations where we are outside of the standard Rails request/response paradigm (ex: in an ActiveJob) any AccountOwnable
models will raise if Current.account
is not set. This forces us to constantly think about how we are scoping data for customer needs.
The situations where we need to enumerate through more than one tenant's data at a time are still possible but now require a Model.unscoped
which can be easily scanned for in linters requiring engineers to justify their rationale on a per use-case basis.
Current.account =
in the Rails console. To make that much easier we wrote a simple console command.# lib/kolide/console.rb
module App
module Console
def t(id)
Current.account = Account.find(id)
puts "Current account switched to #{Current.account.name} (#{Current.account.id})"
end
end
end
# in config/application.rb
console do
require 'kolide/console'
Rails::ConsoleMethods.send :include, Kolide::Console
TOPLEVEL_BINDING.eval('self').extend Kolide::Console # PRY
end
t 1
when we want to switch the tenant with an id of 1. Much better.Current
before each spec/test as it's not done for you automatically. For us that was simply a matter of adding...# spec/spec_helper.rb
config.before(:all) do
Current.reset
end
CurrentAttributes
paradigm in Rails 5.2. DHH talks about his rationale for adding this in his Youtube video entitled, "Using globals when the price is right".Abstract super class that provides a thread-isolated attributes singleton. Primary use case is keeping all the per-request attributes easily available to the whole system.
The following full app-like example demonstrates how to use a Current class to facilitate easy access to the global, per-request attributes without passing them deeply around everywhere:
# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
attribute :account, :user
attribute :request_id, :user_agent, :ip_address
resets { Time.zone = nil }
def user=(user)
super
self.account = user.account
Time.zone = user.time_zone
end
end
# app/controllers/concerns/authentication.rb
module Authentication
extend ActiveSupport::Concern
included do
before_action :set_current_authenticated_user
end
private
def set_current_authenticated_user
Current.user = User.find(cookies.signed[:user_id])
end
end
# app/controllers/concerns/set_current_request_details.rb
module SetCurrentRequestDetails
extend ActiveSupport::Concern
included do
before_action do
Current.request_id = request.uuid
Current.user_agent = request.user_agent
Current.ip_address = request.ip
end
end
end
class ApplicationController < ActionController::Base
include Authentication
include SetCurrentRequestDetails
end
class MessagesController < ApplicationController
def create
Current.account.messages.create(message_params)
end
end
class Message < ApplicationRecord
belongs_to :creator, default: -> { Current.user }
after_create { |message| Event.create(record: message) }
end
class Event < ApplicationRecord
before_create do
self.request_id = Current.request_id
self.user_agent = Current.user_agent
self.ip_address = Current.ip_address
end
end
A word of caution: It's easy to overdo a global singleton like Current and tangle your model as a result. Current should only be used for a few, top-level globals, like account, user, and request details. The attributes stuck in Current should be used by more or less all actions on all requests. If you start sticking controller-specific attributes in there, you're going to create a mess.
#each
block.ApiKey.unscoped.find_each do |api_key|
Current.account = api_key.account
api_key.convert_to_new_format
end
account_id
we gain the future option of leveraging more sophisticated solutions at the PostgreSQL level like multi-DB sharding or even products like Citus.