Rails: How to Reduce Friction at the Authorization Layer
It all started so innocently. I knew that I wanted something to organize authorization for Flipper Cloud. But I wasn't sure what. Pundit? Why not.
I started slapping policies in and peeling out. Over time though, I noticed the same things that I notice on every app I work on โ entropy and friction at the authorization layer.
Steve and I were chatting about it one day over a screen pairing session and he said something to the effect of "Hey, wouldn't it be nice if we had a message in addition to true/false." ๐ก
Yeah, yeah that would be real nice Steve. And you know what, that would be real possible too. So I started pulling at the string and pretty soon I had this big, beautiful ball of ๐งถ that was the envy of every ๐ around.
I've shared snippets of it with friends and they seemed pretty interested, so why not a full on blurgh post.
Authorization != REST
The first string I pulled was the awkwardness of policy names matching REST actions (e.g. index, create and show). This is Ruby. Ruby is a conversation.
policy(organization).view_members?
is a conversation. policy(User).index?
is not.
policy(Project.new).create?
knows nothing other than the type of instance it is. policy(organization).create_project?
knows a lot more.
Here I passed in a class. There I passed in an instance of a class. Some of those instances were persisted records. Others were not.
I couldn't keep it straight. Do I need to check new_record?
or persisted?
here or not? Am I dealing with a class or an instance of a class?
Incongruence
I felt incongruence. Incongruence isn't always obvious. But you feel it. Deep in your programmer bones you feel it.
Notice how some code just feels right and you rock and roll and are on top of the world? But other code requires you to continually re-check a file for the name of a method or parameter or what type of object you are dealing with?
That is incongruence. If it isn't flowing, that means the code is showing (I rhyme all the thyme). Its showing you that something is not right. That is what I kept feeling.
So that was step #1. It might feel minor, but for me it opened up the flood gates of authorization.
Adios in-memory
I changed code like this:
if policy(@organization.projects.new).create?
To code like:
if policy(@organization).create_project?
Oooh, that feels nice ๐. Just like that all-my in memory objects were gone. Again, such a small thing, but something just felt right.
Adios classes
I didn't stop there though. Next I swapped out code like:
if policy(OrganizationMembership).index?
For something more readable and enforceable like:
if policy(organization).view_members?
Every object passed into a policy was a persisted instance. Congruence!
Conquer the Repetition
The next string that I tugged on was all the repetition of checking organization or project membership. Most policies had something like this in them:
class TokenPolicy < ApplicationPolicy
def show?
project_member?
end
def update?
project_member?
end
def destroy?
project_member?
end
private
def project_member?
record.project.member?(user)
end
end
Solving this was mostly delegation plus extract method to superclass. I made sure that every object under the umbrella of a Project or an Organization delegated up the chain.
class Environment < ApplicationRecord
delegate :organization, to: :project
end
class Feature < ApplicationRecord
delegate :organization, to: :project
end
class Gate < ApplicationRecord
delegate :project, to: :feature
delegate :organization, to: :project
end
class Webhook < ApplicationRecord
delegate :project, to: :environment
delegate :organization, to: :project
end
Doing this made it so that project_member?
could move up to ApplicationPolicy
:
class ApplicationPolicy
def project_member?
record.project.member?(user)
end
def organization_member?
record.organization.member?(user)
end
end
Things were starting to feel better. The permissions were more descriptive (not REST) and DRY. But something still didn't feel right.
The Message
The core of Pundit, or most authorization related code, is can someone do something โ true
or false
. But neither true
nor false
tell the whole story.
Any designer or developer worth a lick will typically let a software user know why they aren't allowed to do something. This is really what Steve was getting at in our conversation at the beginning of this post. Sorry for taking so long to get to the ๐.
Predicate Methods
The message of why a user can't do a thing was the tricky part. My code was littered with create_project?
, invite?
and other predicate methods.
Predicate methods in Ruby always return true
or false
. To return some other object (that had message
and result
) would be confusing. It would require every developer who works on this project to learn something new and totally different than idiomatic Ruby.
No, that just wouldn't work. So what was I to do?
What if each predicate method had a companion method that wasn't a predicate and returned some sort of result object.
That method could also be memoized and re-used in the predicate method. Memoizing would avoid incurring a duplicate check of authorization for a single predicate check and output of a message.
Let's look at a sample:
<% if user_policy.create_organization? %>
<%= link_to "Add an Organization", [:new, :organization] %>
<% else %>
<span title="<%= user_policy.create_organization.message %>" data-toggle="tooltip">
Add an Organization
</span>
<% end %>
I mean that reads ok, right? If the user_policy
can create an organization, then the Add an Organization link is shown. If it cannot, then I instead show a tooltip with a title explaining why an organization cannot be created.
No more running checks in policies and then running checks again in a controller or view to figure out why a given policy was false.
No more generic messages like "You do not have permission to do this."
Because we have an exact message for why the authorization failed, we can tell the user exactly why.
The Code Migration
So I slapped a DSL in ApplicationPolicy
that made it easy to define both methods at once.
The best part? Because the predicate methods all stayed the same, no view or other code had to change immediately.
Over time, I picked spots where having more information on why a user couldn't do something would be more valuable and started using the predicate and non-predicate method calls in combination to provide more information.
The DSL
I know, I know, you just want to see my instance_eval
, catch
/ throw
and other terrible things.
But before we get to that, can we just admire this:
class ProjectPolicy < ApplicationPolicy
policy :show, :update, :destroy, :view_features, :view_members, :view_environments, :create_environment do
require_project_membership!
end
policy :create_feature do
require_project_membership!
require_subscription_access! message: "Creating a feature requires a trial or subscription."
end
policy :invite do
require_confirmed_user! message: "Inviting collaborators is disabled until you verify your account by email."
require_organization_membership! message: "Only organization members can invite collaborators."
end
policy :cancel_invite do
require_organization_membership! message: "Only organization members can cancel invitations."
end
end
Maybe I'm just too familiar with this because I wrote it.
But I'm pretty sure you can read the code above and understand the permissions for managing projects in Flipper Cloud โ even if you aren't sure how to use it.
The Response Object
The first layer of this ball of yarn is a PolicyResponse
class. This is what is returned by the non-predicate method and wraps up all the data we'll need.
class PolicyResponse
attr_reader :reason, :message
REASON_TO_MESSAGE = {
organization_membership_required: "You must be an organization member.",
project_membership_required: "You must be a project collaborator or an organization member.",
subject_memberbership: "You must be a project collaborator or an organization member.",
subscription_required: "You must have a subscription.",
anonymous_user: "You must be signed in.",
unconfirmed_user: "You must have a confirmed email address.",
}.freeze
def self.true(reason = nil, message = nil)
new(true, reason, message)
end
def self.false(reason = nil, message = nil)
new(false, reason, message)
end
def initialize(value, reason = nil, message = nil)
@value = value
@reason = reason
@message = message
end
def can?
@value
end
def cannot?
!can?
end
def title
@title ||= (@reason.to_s.presence || "Whoops!").humanize
end
def message
@message_text ||= @message.presence || REASON_TO_MESSAGE.fetch(@reason, "You are not authorized.")
end
end
Nothing fancy. There is a value
, a reason
and a message
. The value is true
/false
. The reason
is typically a short hand symbol representation of "why not?" when the value
is false
. Lastly, the message
is a more human friendly version of the reason
.
You might have also noticed I added a couple of factories (is that the right fancy enterprise programmer lingo?) at the top for true
and false
responses and some default message
values based on the reason
. These are all just to save me a few key strokes when defining policies.
The DSL Method
The next layer on this path to authorization bliss was a tiny DSL in ApplicationPolicy
.
class ApplicationPolicy
extend Memoist
# Define a new policy and the predicate method.
#
# Example:
# policy :show do
# PolicyResponse.new project_member?, :project_membership_required
# end
#
# Returns nothing.
def self.policy(*names, &block)
names.each do |name|
define_method(name) do
catch(:halt) { instance_eval(&block) }
end
memoize name
define_method("#{name}?") do
send(name).can?
end
end
nil
end
end
There it is. Yep, catch
, instance_eval
, memoize
and all kinds of other shameless tactics to reduce friction and start to enjoy authorization again. I really threw the kitchen sink at this.
Anyway, this adds a class method that I can then use in subclasses to define policy methods. This is akin to how belongs_to
, has_many
and other Active Record methods exist in your models.
I should also note that Memoist is gem I often use. It is extend
ed here which adds the memoize
method used in the class method.
You already saw how the policy class method works (at the opening of this section), but didn't know where it came from.
Here's a short example as a refresher:
class ProjectPolicy < ApplicationPolicy
policy :update, :destroy, :view_features, :view_members, :view_environments, :create_environment do
if project_member?
PolicyResponse.true
else
PolicyResponse.false :project_membership
end
end
end
Policy methods that share permissions can be defined together. That means in the aforementioned example that update
, destroy
, view_features
, view_members
, view_environments
, and create_environment
all share the same permission check.
Inside the block you can do whatever Ruby you need to determine if a user is able to do something or not. The only requirement is that a PolicyResponse
is returned.
Pour some sugar on it
Pretty much immediately I got tired of repeating if
/else
and returning PolicyResponse
instances. So I added a few more methods to ApplicationPolicy
to DRY up the repeated permissions.
class ApplicationPolicy
# [snipped for brevity]
private
def has_subscription_access?
record.organization.has_subscription_access?
end
def organization_member?
record.organization.member?(user)
end
def project_member?
record.project.member?(user)
end
def require_confirmed_user!(message: nil)
require_signed_in_user!
deny! :unconfirmed_user, message unless user.confirmed?
PolicyResponse.true
end
def require_signed_in_user!(message: "You must be signed in.")
deny! :anonymous_user, message if anonymous_user?
PolicyResponse.true
end
# Only allow if organization has subscription access.
def require_subscription_access!(message: nil)
deny! :subscription_required, message unless has_subscription_access?
PolicyResponse.true
end
# Only allow project members.
def require_project_membership!(message: nil)
require_signed_in_user!
deny! :project_membership_required, message unless project_member?
PolicyResponse.true
end
# Only allow organization members.
def require_organization_membership!(message: nil)
require_signed_in_user!
deny! :organization_membership_required, message unless organization_member?
PolicyResponse.true
end
# Wherever we are at in the decision process, stop and allow access.
def allow!
halt! PolicyResponse.true
end
# Wherever we are at in the decision process, stop and deny access.
def deny!(reason, message = nil)
halt! PolicyResponse.false(reason, message)
end
# Stop trying to figure out if someone can or cannot do something and just
# return this policy response.
def halt!(policy_response)
throw :halt, policy_response
end
end
Thanks to the delegation (covered a few sections ago) and stolen catch
/ throw
from Sinatra (source), my policies started bringing me joy.
Enough joy that I'm writing this here post for you. That doesn't usually happen when I write authorization code.
The previous project membership check could now be expressed as:
class ProjectPolicy < ApplicationPolicy
policy :update, :destroy, :view_features, :view_members, :view_environments, :create_environment do
require_project_membership!
end
end
With the PolicyResponse
object, the class method and the accompanying helpers I can whip together permissions really easily now.
Sharing and Customizing
I also ensured that policies could be re-used within a method.
For example, I wanted most of the permissions for gates (the ways to enable
/ disable
a feature) to be the same. But I wanted an extra limit on the number of actors or groups enabled for a feature to avoid mis-use.
class FeatureWithEnvironmentPolicy < ApplicationPolicy
policy :manage_gates do
require_subscription_access!
require_project_membership!
end
policy :clear, :mirror, :enable, :disable, :disable_actor, :disable_group, :set_percentage_of_actors, :set_percentage_of_time do
# All of these individual gate operations should also check manage gates.
# Think of manage gates as a super permission check that has to be passed
# first. Then, any specific gate permission checks have to pass too.
manage_gates
end
policy :enable_actor do
if record.flipper_feature.actors_value.size >= Limit.actors_count_limit
deny! :actor_limit, "This feature has reached the limit to the number of actors per feature. Check out groups as a more flexible way to enable many actors."
end
manage_gates
end
policy :enable_group do
if record.flipper_feature.groups_value.size >= Limit.groups_count_limit
deny! :group_limit, "This feature has reached the limit to the number of groups per feature."
end
manage_gates
end
end
๐ฅ All feature gate changes are controlled by manage_gates
. But enable_actor
and enable_group
do an extra check for the set size.
Vanilla pundit can even be used in the controller via the authorize method.
class GatesController < ApplicationController
def enable
authorize @feature_with_environment, :enable?
@flipper_feature.enable
redirect_to [current_organization, @project, @environment, @feature]
end
end
Because authorization became so easy and flexible, I started using the policies in new ways. Check out this :api
policy method I added to the TokenPolicy
.
class TokenPolicy < ApplicationPolicy
policy :api do
deny! :not_found, "Environment could not be found." unless record.environment
deny! :not_found, "Project could not be found." unless record.project
deny! :not_found, "Organization could not be found." unless record.organization
require_subscription_access! message: "Using the API requires a trial or subscription access. Visit: #{Rails.application.routes.url_helpers.organization_subscription_url(record.organization)}"
unless record.enabled?
deny! :disabled, "Disabled tokens cannot access the API."
end
PolicyResponse.true
end
end
Then, in a middleware before every API request, the policy is checked:
policy = token.policy.api
unless policy.can?
if policy.reason == :subscription_required
return error_response(request, 402, policy.message)
else
return not_found(request, policy.message)
end
end
Note that I customize the response code based on the reason
. If I'm dealing with a subscription issue, I can return a payment required status code.
Wrapping Up
Is any of the above a perfect abstraction? No. But it removed the friction I was feeling to the point that I was excited to show boring policies to friends, and to you dear reader.
I think the TLDR is that vanilla pundit, a mindset change, and a wee bit of code authorization moved from annoying to empowering.
Whew. That was a long one, but I hope you enjoyed it and maybe learned a little something. It took me around 4+ hours to write.
If you did enjoy or learn anything, you should probably check out Flipper Cloud. Sign up, kick the tires and let me know what you think. I crave feedback.