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.
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.
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.
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
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?).