Revisiting User Authorization

It’s been 8 years since I last posted here. A long time that just so happens to roughly coincide with the birth of my first child. Who would have thought :) It seems appropriate to revisit the content of my first post as I am currently working on a new project and I am still using this little auth method, with an upgrade.

I will include a detailed explanation below of each change, but the idea is still grokkable looking at only 3 files.

1db/schema.rb
2
3create_table "users", force: :cascade do |t|
4  ...
5  t.text "roles", default: ["user"], null: false, array: true
6  ...
7end

User schema includes a roles attribute that defaults everyone into the user group. I use this as a convention for: “everyone with an account can interact with this action”. I suppose, if for some reason you had a more complicated use-case, you could have users who are not in the user group.

 1app/lib/authorization_policy.rb
 2
 3module AuthorizationPolicy
 4  def self.roles
 5    %w[user admin your_custom_role_here]
 6  end
 7
 8  POLICIES = {
 9    samples: {
10      index: %w[user],
11      show: %w[user],
12      edit: %w[admin],
13      update: %w[admin],
14      new: %w[admin],
15      create: %w[admin],
16      destroy: %w[admin]
17    }
18  }.freeze
19
20  # Returns true if any of the user's roles match an allowed role for the
21  # (controller, action) pair. Both controller and action are looked up in
22  # POLICIES as symbols.
23  def self.authorize(user_roles, controller, action)
24    allowed = POLICIES.dig(controller.to_sym, action.to_sym)
25    return false if allowed.nil?
26    allowed.intersect?(Array(user_roles))
27  end
28end

self.roles is a simple convenience method to get all roles that exist in the application, skip it if you don’t need it.

POLICIES is a hash of each controller and action you want to guard.

self.authorize(...) returns a boolean:

  1. false if you haven’t listed a policy for the controller/action pair
  2. false if none of the passed user_roles match an allowed role for the controller/action pair
  3. true if any of the passed user_roles matches an allowed role for the controller/action pair
 1app/controllers/application_controller.rb
 2
 3class ApplicationController < ActionController::Base
 4  helper_method :authorized?
 5
 6  # View helper: `authorized?(:controller_key, :action)` returns true if the
 7  # current user can perform the action.
 8  def authorized?(controller, action)
 9    user_roles = current_user&.roles || []
10    AuthorizationPolicy.authorize(user_roles, controller, action)
11  end
12
13  private
14
15  # Controllers opt in by adding `before_action :authorize`. Returns 403 (or
16  # for HTML, redirects back) when the current user's roles don't include
17  # any of the controller/action's allowed roles.
18  def authorize
19    return if authorized?(controller_path_key, action_name)
20
21    respond_to do |format|
22      format.json { head :forbidden }
23      format.any  { redirect_back fallback_location: root_path, alert: "Not authorized." }
24    end
25  end
26
27  # Translates the controller path (e.g. "pwa/sync") to a policy key
28  # (:pwa_sync). Override in a controller to customize.
29  def controller_path_key
30    controller_path.tr("/", "_").to_sym
31  end
32end

authorized? is exposed as a helper method for the views (see examples below).

authorize is called on the controller when you want to apply the authorization module.

controller_path_key is a convenience method so you don’t have to do gsub gymnastics if you use namespaced controllers (something the old method did poorly, see changes below).

Examples

Let’s take a scenario where your samples_controller auth is shaped like it was above: everyone can view the index and show page, but only admin can make any changes. The samples_controller and view code look like the following:

1app/controllers/samples_controller.rb
2
3class SamplesController < ApplicationController
4  before_action :authorize
5
6  ...other controller code...
7end
1app/views/samples/show.html.erb
2
3<%= link_to "Edit", edit_sample_path(@sample) if authorized?(:samples, :edit) %>

And that’s it. The controller endpoints are protected: even if users type URLs directly or hit endpoints with curl (you’ve got some hardening challenges if you have users like this), they get an unauthorized response. And to protect yourself from angry tickets, the links they can’t use don’t show up in the views anyway.

A self bonus

Some of the projects I deal with need an additional layer of complexity. In their case, you have general auth rules (only admins can edit this record) and a carve out (unless it’s your record). Let’s take a specific example of a user’s contacts. The shape of the auth block looks like:

 1app/lib/authorization_policy.rb
 2
 3POLICIES = {
 4  ...
 5  contacts: {
 6    index: %w[admin self],
 7    show: %w[admin self],
 8    new: %w[admin self],
 9    create: %w[admin self],
10    ...
11  }
12  ...
13}.freeze

You see the regular admin, but also a call to self, which is actually a kind of pseudo-role. It is injected by application_controller.

 1app/controllers/application_controller.rb
 2
 3class ApplicationController < ActionController::Base
 4
 5  ...
 6
 7  def authorized?(controller, action)
 8    AuthorizationPolicy.authorize(effective_roles, controller, action)
 9  end
10
11  private
12
13  ...
14
15  def effective_roles
16    @effective_roles ||= begin
17      roles = (current_user&.roles || []).dup
18      roles << 'self' if self_request?
19      roles
20    end
21  end
22
23  def self_request?
24    return @self_request if defined?(@self_request)
25    @self_request = params[:user_id].present? && params[:user_id] == current_user.id.to_s
26  end
27end

The first argument to AuthorizationPolicy.authorize has been changed to effective_roles, a method that duplicates the user’s roles and injects self (if it’s a self request). The nil guard is moved there as well. self_request? is what you’d expect: it compares current_user.id against the user ID in the path. The memoization of @effective_roles and @self_request buys you some performance if you call it across a lot of links on one page.

2 notes:

  1. self_request? is brittle. In the project this came from, almost all records had a :user_id param that we could grab and we specifically treated places where an :id param was referring to a user.
  2. The instance variables can give you an escape hatch to writing a more complicated matcher if you know your params are going to come in differently (but you must use prepend_before_action when setting to get in before the memoization if you are calling before_action :authorize). Set @self_request to true in the prepended before action with whatever logic is required to figure out if it’s the user that’s knocking.

Changes

This is probably mostly for me, but if you run with this code, maybe there is something useful for you here too.

authorization_policy.rb

1. Move policies to its own hash

Initially the hash was part of the function that returned it. Breaking it out into a constant means the entire auth hash isn’t being rebuilt on every call (and there are usually multiple calls on every page); freezing it on top of that protects against accidental mutation.

One other benefit you might find from breaking the object out to its own hash is composing auth hashes. I will admit, when you have dozens of controllers, it gets hard to read a giant hash to find the auth you want. It’s not a huge problem as you can quickly search for your controller name (or maybe you are so organized as to put your hash in alphabetical order), but it might be an improvement. If you use namespacing a lot, I think that’s a natural fit to break hashes on and compose them back into the main hash.

2. The authorize action

I’m a little embarrassed to have published the previous method. & .empty? and !…what a soup. But, it worked for many years despite being hard to read. Now, if you’re running Ruby 3.1 or later, you can use Array.intersect? which I think reads much cleaner: “do any of the allowed roles intersect with the user’s roles?”

One other thing you’ll notice about the new method is the early return when dig comes up nil: it won’t 500 if you haven’t listed an auth group (or a controller). This probably falls into the personal preference category. For many years I was happy to get a 500 error in dev if I forgot to add a group or list a controller. Later though, as sites grew and requirements became increasingly complex, keeping up with all those view links and remembering to wrap/unwrap/re-wrap just became unmanageable. Now, my default is to wrap if a site uses auth (even a little bit). Want to make an edit link available to everyone? Easy, just add user to the edit action, all links are already wrapped, boom, done. Want to hide the link to create a new widget? Don’t reach for grep, just empty out the new action’s array, boom, done.

3. Swap for the plural

Top-level POLICIES keys are the plural controller-resource name (e.g. samples, contacts) rather than the singular. Either works, but matching Rails’ default plural controller_path means controller_path_key resolves with a simple .tr instead of a bunch of substitutions.

application_controller.rb

1. Remove defaults in authorized?

When I first wrote this module I had the idea that I would call it like this:

1<%= link_to "Edit", edit_path(@thing) if authorized? %>

Clean, easy, lends itself to wrapping everything as I described above. Needing to wrap links for a controller and action other than the one rendering the current view will happen almost immediately, and two different syntaxes, often in the same view, are a mental tax. What I did, in practice, was always specify controller and action. The new way it’s written just codifies that by removing the defaults.

2. Respond to multiple formats in authorize

That first app I was writing this for was pure SSR. Even the forms did not submit with the remote: true attribute, but I send more formats than just HTML these days. The other change is the early return instead of unless. I know some people don’t like it, but I love Ruby’s unless conditional. The early return is just cleaner here.

3. Flatten namespaces instead of trying to extract them

The first update to the original post gives a very long and, to put it nicely, inelegant solution to namespaces. The new way to handle them is just to flatten using the controller_path_key. Now a namespaced controller like Admin::UsersController returns :admin_users. This is something you need to keep in mind when forming keys in the auth hash.