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:
falseif you haven’t listed a policy for the controller/action pairfalseif none of the passeduser_rolesmatch an allowed role for the controller/action pairtrueif any of the passeduser_rolesmatches 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:
self_request?is brittle. In the project this came from, almost all records had a:user_idparam that we could grab and we specifically treated places where an:idparam was referring to a user.- 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_actionwhen setting to get in before the memoization if you are callingbefore_action :authorize). Set@self_requestto 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.