Published from docs/guides/checking-permissions.md. Edit that file — this page is generated when the docs site builds.

Checking permissions

See also: Concepts & glossary.

Checking permissions — anywhere

allowed_to? is available in controllers and views via Context, and in any PORO or ViewComponent by mixing in CurrentScope::Permissions. No current_user threading, ever:

allowed_to?(:approve, report)         # key derived from the record → reports#approve
allowed_to?(:create, Report)          # class form for collection actions
allowed_to?("admin/reports#approve")  # explicit key when you need it

Key derivation agrees with the gate when the current controller’s path ends in the record’s route key: inside Admin::ReportsController (path admin/reports, route key reports), allowed_to?(:approve, report) resolves to admin/reports#approve — exactly what the Guard enforces there — and a cross-resource check from a projects view resolves to reports#approve.

Residual foot-gun — namespaced/custom-named controllers. When a controller’s path segment differs from the record’s route key (e.g. a DashboardController that renders Reports: path dashboard, route key reports), the short-form allowed_to?(:show, report) derives reports#show while the Guard enforces dashboard#show — so a link may show that then 403s (or hide that would work). The Guard stays authoritative, so this is a display bug, not a bypass. In such controllers, prefer the explicit full key — allowed_to?("dashboard#show") — which removes the ambiguity. The short form is only guaranteed to match the gate when path segment == route key.

Residual foot-gun — allowed_to? never consults the catalog. The Guard raises if you gate an uncataloged key. Advisory allowed_to? does not: a typo (allowed_to?("reprots#show")) is a silent false (button never shows); a raw grant row for a removed route can make allowed_to? true for a key the gate will never see. Fail-closed either way, but a debugging wall. Prefer keys that appear in the permission grid; treat the Guard as authoritative. (#36)

class ApproveButtonComponent < ViewComponent::Base
  include CurrentScope::Permissions

  def render? = !report.approved? && allowed_to?(:approve, report)
end

Scoping a list (scope_for)

allowed_to? answers “may I act on this record?”. scope_for answers the list-side question — “which records may I act on?” — from the same roles, permissions, and scoped grants the gate reads. Both sides key grants on polymorphic_name (the stored token), so a custom type name cannot pass the record check and miss the list. Use it for index pages so the list and the per-record gate stay one source of truth, never a hand-written query that drifts:

# app/controllers/projects_controller.rb
def index
  @projects = scope_for(Project).order(created_at: :desc).page(params[:page])
end

private

# A collection-only controller declares BOTH hooks. current_scope_record = nil
# is what tells the gate "this action has no record" (a scoped grant can then
# open it); WITHOUT it the gate assumes nothing and current_scope_model is
# inert — the grant never opens the gate. current_scope_model then names the
# TYPE, so the grant opens the record-less gate only for Projects. (A
# controller with member actions already has current_scope_record; it just
# adds current_scope_model.)
def current_scope_record = nil
def current_scope_model = Project
  • full-access or an org-wide grant of the key → every record (Project.all).
  • scoped grants → only the specific records that role was granted on, plus the children of granted parents when the model declares a chain (below).
  • no grant (or no subject) → empty, fail-closed like the gate.

A grant on a parent record (#108)

Scoping is flat by default: a role held on Project 7 matches actions on Project 7 and nothing else. Opt a model in when authority should reach down a level:

class Report < ApplicationRecord
  belongs_to :project
  current_scope_parent :project

  # Unchanged, and it still names the REPORT's requester.
  def current_scope_initiator = requested_by
end

Now a scoped role held on Project 7 satisfies reports#approve on that project’s reports, including reports created after the grant, and scope_for(Report) lists exactly those reports.

Three things to know before you declare one.

A scoped full_access grant does not cascade. Only roles that explicitly tick the key reach children. A scoped full_access grant on a root record would otherwise open every permission on everything beneath it, which is a much larger grant than the operator who ticked one box intended. The side effect is that privilege stops being monotonic here: a full_access role reaches fewer records through a chain than a role that merely ticks the key. If you want blanket authority over a subtree, tick the keys.

The four-eyes veto still reads the record you handed back, never an ancestor. A lead holding a grant on Project 7 still cannot approve a report they requested themselves. This is the whole reason the chain feeds grant matching only — the older workaround, handing the parent back from current_scope_record so the grant would match, moved the record the veto reads and silently blinded it.

It is a class macro, not a method. Every other current_scope_* hook is a plain method you define. This one names an association instead, because scope_for has to build a query from the foreign key and a method returning a parent instance cannot give it one. Writing def current_scope_parent = project raises rather than being ignored.

Declaration errors raise; data never does. A missing association, a has_many, a polymorphic or scoped belongs_to, or a declaration on an STI subclass all raise ConfigurationError at the macro with the fix named. A custom association primary key is refused later by ParentChain.validate_declarations! (on to_prepare, plus after initialize when config.eager_load is true), because the check needs reflection.klass and resolving that inside the macro breaks ordinary forward references — see UPGRADING.md for when that pass does and does not see a model. The shape of your rows is not a misconfiguration: a chain longer than five hops, or a parent_id loop, stops the walk where it runs out, denies, and logs one warning per model. It never raises, because a loop in the data is two UPDATEs and must not turn a live request into a 500. If a subject is missing access they should have, look for “current_scope_parent stopped walking” in the log.

When a scoped grant reaches nothing

bin/rails current_scope:report and the role members view flag two shapes before you flip config.enforcement to :enforce:

  • cannot match — proven. The role ticks no permissions, or only keys that no route produces. Tick a key, or remove the grant.
  • check hooks — advisory. No ticked key names a controller for the grant’s record type. The engine cannot prove this (current_scope_record runs at request time), so a controller serving that type under another name is a false alarm. Read the hook before removing anything.

Neither is the same as inert (a grant whose record was deleted), which has a third fix: remove the grant. See Limitations A16.

The gate agrees. A collection action like #index has no record to name, so it asks a record-less question, bound to the type the controller declares (current_scope_model, above). For a collection read (config.collection_read_actions, index by default) the gate asks scope_for itself: the subject reaches the list exactly when it would show them records — scoped full_access grants included — and the two halves cannot disagree, because they are one query. Any other record-less key needs a scoped grant whose role ticks it explicitly; scope_for then narrows the list to the records they were actually granted. A grant on a Report never opens a Projects gate — the type is what binds them. No org-wide grant is needed to reach a scoped index (and reaching for one would defeat the purpose — an org-wide grant means “see everything”, so scope_for would return Project.all). The same holds for the class form, allowed_to?(:index, Project), which carries the type as its argument, so a view helper and the gate never disagree. A controller that does not declare current_scope_model fails the record-less gate closed for scoped grants (the denial carries X-Current-Scope-Reason: model_undeclared, and a dev nudge names the one-line fix). A declaration that returns something other than a concrete ActiveRecord class — "Report" for Report, say — also fails closed, labelled model_invalid, with a nudge naming the value the hook returned.

The gate admits; scope_for narrows. Both halves are yours to wire. The gate only decides whether #index runs — it cannot filter a list you build with Project.all. If a scoped role ticks a collection key and that action doesn’t call scope_for, the subject reaches the action and sees everything it queries. Gate a collection action for scoped roles only alongside a scope_for list.

Off the read list the rule is uniform: a scoped role that ticks create or a bulk key opens those collection gates too, exactly as an org-wide grant of the same key already does. Tick a collection key on a scoped role only when you mean it — there is no record filter on create.

A scoped full_access role follows the read/write split: it opens the listed reads of its record’s type — the gate derives from which records the grant actually holds, so “Owner of Project #7” reaches the project index and sees Project #7 — and nothing else record-less. A full_access role satisfies every key, so honoring it in a record-less check that answers with a bare boolean would make one scoped grant a pass on every #create in the app; the read gates are safe precisely because their answer comes from the list. Two consequences worth knowing: a grant whose record is absent from the model’s default scope — destroyed, soft-deleted, or scoped out by a tenant default_scope — opens nothing (an empty list is a 403, not an empty page), and the declared current_scope_model is trusted like the record hook — a wrong declaration opens that controller’s listed reads to full_access holders of the declared type, so review the declaration the way you review current_scope_record.

It returns a chainable ActiveRecord::Relation, so .where/.order/.page compose normally. permission: defaults to the model’s index key and accepts a bare action or a full key (scope_for(Report, permission: :approve)).

Every record scope_for(Project) returns passes allowed_to?(:index, project), and every record it omits fails it — by construction, not by convention. It resolves against the effective subject, so acting-as changes what lists show. A scoped grant lists that record only — plus the descendants of granted records when the model declares current_scope_parent (see above). SoD does not apply — it vetoes record-targeted actions, not list membership.

Record-level decisions

Member actions that need scoped roles or the SoD veto declare a hook. It runs before your own before_actions (the gate comes first), so it loads the record itself; memoize so your set_* callback reuses it. Key off request.path_parameters, never params — a ?id= query string must not smuggle a record into collection actions:

class ReportsController < ApplicationController
  private

  def set_report = @report ||= Report.find(params.expect(:id))

  def current_scope_record
    set_report if request.path_parameters[:id]
  end
end

The same hook has two foot-guns worth knowing before you ship: returning nil on an SoD member action silently skips the veto (Separation of duties), and loading with Model.find means an unauthorized caller sees 403 for an existing id vs 404 for a missing one — a record-existence oracle (security checklist mitigation).

Scopeable models

include CurrentScope::Scopeable in a host model to list it in the scoped-role picker’s type dropdown, and give records a nice label with current_scope_label:

class Project < ApplicationRecord
  include CurrentScope::Scopeable

  def current_scope_label = "#{name} (##{id})"   # optional; defaults to "Project ##{id}"
end

This is browse-only sugar — it does not gate anything. The raw-GlobalID path still accepts any model as a scoped-role target whether or not it opts in, and once a record of such a type is linked the console will list that type’s records too, so a full-access operator can browse a table you never registered; the mixin only decides what shows up in the dropdown unprompted. current_scope_label is a plain instance method, so your own definition always wins over the default.

Which roles may be granted on a type (#183)

A role’s permission bundle is written for one shape of record. When that record declares a current_scope_parent, a grant held on the CONTAINER resolves for every record inside it — so granting a per-record role on the container hands the subject that per-record surface across the whole container. One wrong pick in a dropdown, and by default nothing objects.

A type can say what it accepts:

class Workstream < ApplicationRecord
  include CurrentScope::Scopeable
  self.current_scope_grantable_roles = %w[Lead]
end
  • Absent a declaration nothing changes: every role stays grantable on every type. Opting in is per type.
  • The rule is enforced on the model, so a seed, a rake task and a console one-liner meet it as well as the management UI. It is a validation, so the write paths that skip validations skip it too: insert_all, upsert_all, update_column and update_columns write whatever they are given, and no database constraint stands behind them. The picker narrows the type dropdown to those that accept the chosen role, and says how many it withheld.
  • An empty declaration, self.current_scope_grantable_roles = [], means no role may be granted on that type. It is an assignment rather than a DSL call precisely so a computed empty list declares the lockdown instead of silently reading the current value. Assigning nil is the opposite: it means no declaration, so a list read from config with a missing key leaves the type as it was rather than locking it. “As it was” means the accept-everything default on a plain class, and the parent’s declaration on a subclass, which is the next bullet’s inheritance rule: SpecialInvoice.current_scope_grantable_roles = nil under a locked-down base inherits that lockdown. Mind the difference when you compute the list: ENV.fetch("CS_ROLES", "").split(",") is [] with the variable unset, which is a lockdown, and that is the fail-closed answer. Assign nil when you mean “no declaration”.
  • A subclass inherits its parent’s declaration until it states its own, and an STI subclass is governed by its own declaration even though the grant row stores the base class’s token. That needs the record: when the row it names is gone, or its stored id is not a canonical key for the model, the check has only the stored token to go on and judges the grant by the base class. One table therefore holds records with different answers, so the picker keeps every class over an STI table in the type dropdown and narrows the record list instead. How far the picker READS is decided from descendants, which sees only loaded classes: in an environment that does not eager-load, a declaring subclass nothing has referenced yet can leave a grantable record past the display cut until the next request, and production eager-loading closes it. What the picker SAYS is decided from the rows instead, because a message that is wrong is worse than a fetch that is narrow.
  • CurrentScope::GrantableRoles can be included on its own if you want the rule without the picker registration.

Roles are matched by NAME, and that is the trade a declaration written in code makes: role ids are per-database and cannot be named in a model file. So renaming a role stops the declarations that name it from matching, and a new role that reuses the name inherits its acceptance. If you rename a role, grep for its old name in your models, and run bin/rails current_scope:report: it lists the grants whose type would refuse them today, which is where a rename shows up. A rename is allowed through both the model API and the console, including when the console submits an unchanged permission bundle. Existing grants remain in place. Later bundle edits must stay within their resource permission ceilings; the old name list does not prevent a safe bundle edit. A custom host current_scope_grants_role? override still controls compatibility checks.

Adding a declaration does not rewrite or delete any grant already in the table. It does apply the next time such a row is saved, though: the check runs on every write, not only on create, so a pre-existing pairing the new declaration refuses will fail validation if host code saves that row again. That is deliberate. assignment.update!(role: other_role) has to meet the same rule as the grant that created it, or the console’s gate would be one update wide.

Checking several subjects for one record

Use the resolver’s batch method to build an approver picker without one grant query per candidate:

approvers = CurrentScope.resolver.allowed_subjects(
  subjects: User.active,
  permission: "reports#approve",
  record: report
)

The method returns an array in candidate order. It uses the same separation-of-duties check and scoped-grant relations as allow?. A persisted record or a draft with persisted parents is supported. A nil record or model class raises ArgumentError; use allow? and scope_for for collection decisions. Each call reads current grants and retains no batch cache.

An optional actor: applies to every candidate. Omit it when listing independent approvers. If one candidate is the effective user in an impersonated session, check that candidate with its real actor separately. Host ownership, active-user, and workflow rules remain the host policy’s responsibility. cascade: false excludes ancestor grants, as it does for allow?.