Module-Wise Permissions: A Practical Rails Authorization Pattern

Scoping permissions to controller × action instead of hand-typed strings, generating them from your actual routes, and enforcing them with one line per controller.

A common first attempt at permissions is a flat list of hand-invented names: "manage_billing", "view_reports", "edit_users". It works until the list grows past a dozen or so entries, at which point two problems show up reliably: nobody agrees on naming ("edit_users" vs "users_edit" vs "manage_users"), and there's no structural link between a permission string and the code it's supposed to gate — a renamed controller or a removed action leaves a dangling, silently-dead permission row behind with nothing to notice it happened.

The pattern: permission = controller × action

Module-wise permissions fix the naming problem by deriving the permission directly from the thing it protects: the controller (the "module") and a normalized action. Argus::Trail represents this as two columns instead of one name — module_name: "admin/accounts", action: "read" — and normalizes the seven standard REST actions down to four verbs so a role's checkbox list doesn't need six near-duplicate rows for one resource:

index, show   → read
new, create   → create
edit, update  → update
destroy       → destroy
(anything else — a custom action — kept as-is)

That mapping is exactly what Argus::Trail::Authorizable uses to check a request and what bin/rails argus_trail:fetch_permissions uses to generate permissions in the first place — both sides of the gate agree on the same convention because they're reading it from the same place.

Generating permissions from your routes

Instead of typing out every module_name/action pair by hand, scan for them:

bin/rails argus_trail:fetch_permissions

This walks Rails.application.routes, skips the engine's own routes and framework-internal ones (health checks, Active Storage, Action Mailbox/Text — more via config.permission_scan_excludes), and creates a Permission for every remaining controller/action pair it finds. It only ever adds — safe to rerun after adding new controllers or actions, so the permission list grows alongside your app instead of drifting out of sync with it. A companion task removes the other direction of drift:

bin/rails argus_trail:fetch_permissions:prune

Removes permissions whose route no longer exists and aren't granted to any role — one still granted to a role is left in place and reported instead of silently deleted, so renaming a controller never quietly revokes something a role was actually depending on.

Enforcing it: one line, not one policy per controller

Because the convention is structural (controller path + normalized action), enforcement doesn't need a hand-written policy class per resource — it can be derived the same way the permissions were generated:

class AccountsController < ApplicationController
  include Argus::Trail::Authorizable
end

This adds a before_action that reads controller_path (e.g. "admin/accounts") and the current action, normalizes the action the same way fetch_permissions did, and checks it against the signed-in actor's roles via actor.has_permission?(module_name, action) — a 403 if there's no match. The same method works for a plain, hand-typed permission too (single-argument form), so a few structural, route-shaped permissions and a few one-off named ones coexist in the same table without needing two different APIs:

user.has_permission?("admin/accounts", :read)   # module-wise
user.has_permission?("manage_billing")          # plain name

What this pattern doesn't solve

Module-wise permissions answer "can this role access this controller action at all" — resource-shaped, not record-shaped. "Can Alice edit this specific invoice, but not everyone else's" is a different, per-instance question that a flat permission table can't express on its own; that's exactly where Pundit's policy scopes still earn their place alongside this, rather than being replaced by it (see What is RBAC for Rails?).

Further reading