Published from
docs/guides/configuration-reference.md. Edit that file — this page is generated when the docs site builds.
Configuration reference
See also: Concepts & glossary.
Configuration
Everything lives in config/initializers/current_scope.rb (created by the install generator): the user_method, the subject_class, sod_actions, excluded_controllers (keep infrastructure out of the grid), and parent_controller (what the management UI inherits from). management_authorizer optionally replaces the management console’s default full-access policy. The three impersonation knobs — actor_method, allow_mutations_while_impersonating, and sod_identity — are grouped in their own block and covered under Impersonation; they layer in that order, so sod_identity is only observable once a mutation is allowed past the read-only gate.
Role definitions (name, description, full_access, permission keys) can move between environments as YAML. That is not a config knob: see Portable role definitions. Assignments are not in that document.
config.subject_identity — how a subject is identified for portable, cross-environment use. Default nil is the primary key, so existing installs change nothing. A Symbol names one column (:email). An Array of symbols is a composite ([:name, :email]), stored as a list, never a joined string. An object with identify(subject) and resolve(key) covers a key split across tables. A String or Proc is rejected at assignment — that shape is subject_label, which is display-only and fail-soft. Identity is load-bearing: duplicate natural keys raise ConfigurationError at boot (skipped during db: tasks, and skipped for the default primary key). resolve returns nil when missing and never inserts. A blank identity column raises too: identify refuses to mint a key that resolve could never find. CurrentScope.identify_subject(subject) and CurrentScope.resolve_subject(key) are the public entry points for the key itself: identify_subject returns the portable key for a record, resolve_subject returns the record for a key in this environment, or nil. identify_subject refuses a record that is not config.subject_class, because resolve_subject only ever returns one of those, so a key minted from another model would resolve to a different record.
Duplicate detection follows your database’s collation, because it compares your own identity columns. MySQL’s default collation is case-insensitive, so Ada@example.com and ada@example.com count as one duplicate key there and as two distinct keys on PostgreSQL and SQLite. Each database stays self-consistent (resolve matches under the same rules), but a host that tests on SQLite and deploys on MySQL can meet a boot refusal that CI never showed. Run current_scope:identity:check against a copy of production data, or use a case-insensitive unique index, if that difference matters to you.
For a Symbol or Array identity, put a plain unique index on exactly those columns and the boot check answers from the index, without scanning the subject table. Without one it is a grouping query over the subject table. An identity OBJECT owns its own unique?, so boot pays whatever that method costs. To list every duplicate, run bin/rails current_scope:identity:check. Guided attach: bin/rails current_scope:identity:setup IDENTITY=email SUBJECT=you@example.com (dry-run) then WRITE=1 to call grant!. PLACEHOLDER=1 needs a create_placeholder! factory on the identity object from bin/rails generate current_scope:identity; without one the task stops with “PLACEHOLDER=1 has no factory”. With a factory it writes a marked row only together with WRITE=1, and only outside production. Never invent a production subject.
config.subject_label is not the same knob. Label names a subject in the management UI and is allowed to fail soft. Pointing both at :email does not make the label a resolver.
config.polymorphic_class_names — optional Hash of stored type token to class name, for a custom polymorphic_name that Rails cannot reverse. Default {}. Auto-detected overrides (loaded models whose token is not the Rails default name) merge with this map. Auto-detection sees only the models loaded when the registry rebuilds: under eager_load (production) that is every model, so the map is authoritative; in development a custom-token model autoloaded after boot is not reverse-resolvable until the next reload. Map a token here when it must resolve regardless of load timing. Two classes that claim the same token raise at rebuild, including a shortened name that matches another loaded class. The named class must actually store that token; an unknown class name raises. A token is the stored grant identity: if you retire a model and later give its token to a different class, existing grants rebind to the new class, so treat a token rename or reuse as a data migration.
config.enforcement — :enforce (default) | :report. What the gate does with a denial. :enforce means a denial is a 403; it is the only production posture. :report logs a missing grant and lets the request through instead, recording it as access.would_deny — the adoption ramp for retrofitting an existing app, covered in Adopting CurrentScope in an existing app and the short retrofit recipe in the README Installation section. It relaxes nothing else: the SoD veto and the management console are untouched by it. An unknown value raises at boot rather than being silently treated as one of the two — believing you’re enforcing when you aren’t is the worst way to be wrong about this setting.
config.collection_read_actions — ["index"] by default. The record-less actions whose gate derives its answer from the scoped list, so a scoped full_access grant opens exactly the collections that would show its records (gate and list agree by construction — the #65 fix). Set [] to restore the pre-#65 behavior, where explicit ticks still open type-bound record-less gates but scoped full_access opens none (the whole record-less family is new in this release — no released version had either posture). A full key ("reports#index") raises at assignment (the list is action-segment matched, app-wide), and a canonical mutating name (create/update/destroy) logs a loud warning. List-narrowing reads only: never name a mutating action here — that would hand a scoped full_access holder the action on every record of the type off a grant on one record. Custom read actions (export, search) are the intended additions. Members normalize to strings on assignment, so %i[index] works.
The audit ledger is controlled by config.audit — tri-state false | true | :strict. false records nothing; true (the default) records authorization changes made through the management UI, the impersonation boundary, and CurrentScope.grant! (including the rake task and seeds bootstrap path — self-attributed, details.source = "bootstrap"), and degrades gracefully (skip + warn once) if the events table isn’t migrated; :strict raises on a missing events table so an audit-mandatory app never commits an unaudited change (the mutation rolls back).
Since #182 the ledger no longer depends on which door a GRANT was created or destroyed through (updates are a separate matter — see the table below). scoped_role.granted, scoped_role.revoked, org_role.removed and role.deleted are emitted from model callbacks, so a seed, a rake task, a console one-liner and the grant_scoped_role! test helper all record what the management UI records. (grant_role! is a direct RoleAssignment.create!, and org-role creation is one of the five writes named below as still unrecorded.) Every event that CHANGES an authorization carries details.attribution — org_role.assigned, org_role.changed, org_role.removed, role.created, role.updated, role.renamed, role.deleted, scoped_role.granted and scoped_role.revoked. It is "actor" when an ambient identity existed (Current.actor answers super || user, so a request, a job, an ambient user or with_current_user in a test all produce it), "self" when none did, in which case the row is self-attributed to the record it is about. CurrentScope.grant!’s rows are self-attributed too, so they read "self"; what marks them as the bootstrap path is their separate source: "bootstrap".
It is attribution and not source because details["source"] was already taken, twice and for different things: CurrentScope.grant! writes source: "bootstrap", and definitions.applied / definitions.rolled_back write the file path the document came from. Filter on attribution to isolate who was behind an authorization change; filter on source for neither.
The OBSERVATION events carry no attribution and are not meant to: impersonation.started / .stopped, sod.bypassed, access.would_deny, access.sod_blind_spot and access.sod_initiator_missing record what happened at the gate rather than a change to a grant. The two definitions events are authorization changes and carry no attribution either — their actor is the one the document was applied with. Do not read an absent attribution as “not a human”.
What is recorded from the model is creation and destruction of scoped grants, and destruction of org-role assignments and of roles. Five write paths are still silent, and it is worth knowing which before you rely on the ledger:
| Write | Recorded? |
|---|---|
ScopedRoleAssignment create / destroy | yes, from the model |
ScopedRoleAssignment#update! direct (role:, subject:, resource:) | no — the callbacks are after_create and after_destroy; re-pointing a live scoped grant leaves no row |
RoleAssignment destroy, Role destroy | yes, from the model |
RoleAssignment.create! direct | no — CurrentScope.grant! is the documented path and carries the from/to a callback cannot see |
RoleAssignment#update!(role:) direct | no — same reason; a console re-grant leaves no trail |
Role.create! direct | no — role.created carries the initial permission set, which is not persisted when an after_create runs, and moving it to after_commit would forfeit the :strict rollback |
Role#update! direct (full_access, permission_keys) | no — role.updated / role.renamed are emitted by the console |
So a privilege change made by update! in a console or a seed still leaves no row. Use the management UI, CurrentScope.grant! or the definitions document for changes that must be auditable.
UI events stamp request_id from ActionDispatch::RequestId via the Context hook.
Note on the
!: despite the bang,Event.record!only guarantees raise-on-failure under:strict(and for a missing actor). In the defaulttruemode a missing events table is a warn-once no-op, and underfalseevery call silently returnsnil— so a mutation-wrapping transaction does not roll back on a failed audit write unless you opt into:strict.
Management authorization
config.management_authorizer defaults to nil: only subjects with an organization-wide full_access role can enter or change the console. Set a callable to delegate role administration under a host policy. It receives the effective subject as its positional argument and action:, role: and target: keywords. Only literal true permits the operation. A configured callback also decides for full-access subjects; they do not bypass it.
CurrentScope.configure do |config|
config.management_authorizer = lambda do |subject, action:, role:, target:|
RoleAdministration.allowed?(subject, action: action, role: role, target: target)
end
end
CurrentScope.can_manage?(:update_role, subject: editor, role: proposed_role)
CurrentScope.can_manage?(:assign_scoped_role, subject: editor, role: reviewer_role,
target: recipient)
RoleAdministration in this example is a host-defined policy. The engine does not impose an administrator tier, a held-permission ceiling, or protected recipient rules; the host must implement those requirements in its callback. The callback does not replace application permissions or the impersonation mutation gate. The console retains its last-full-access-holder protections.
| Action | Decision context |
|---|---|
:access | Console entry; role and target are nil. |
:create_role, :update_role, :destroy_role | The role being created, edited or deleted; target is nil. |
:assign_role, :revoke_role | The organization-wide role and recipient. Clearing an absent assignment can supply role: nil. |
:assign_scoped_role, :revoke_scoped_role | The scoped role and recipient. |
For scoped operations, target is the recipient, not the resource. The callback receives no resource keyword. This API supports role- and recipient-based delegation; it cannot express resource-specific console administration rules. Resource-type compatibility is a separate declaration, described below.
Keep the callback a pure predicate. It can run several times for one request, including when rendering controls. Role updates check both the saved role and a candidate carrying submitted attributes and permission keys. The full-access checkbox checks a separate candidate with full_access = true, even when the user has not selected it. Existing-role candidates keep their persisted identity. Do not save candidates, emit audit events or consume a quota from this callback; an authorization question is not a completed write. role and target can be nil, including for unavailable assignment subjects.
CurrentScope.can_manage? accepts (action = :access, subject: CurrentScope::Current.user, role: nil, target: nil) and returns a boolean without performing the operation. A missing subject is denied. With no callback it checks the subject’s full-access role; otherwise it calls the configured predicate. A non-callable setting raises CurrentScope::ConfigurationError when checked. A configured-policy denial uses AccessDenied#reason == :management_denied; the default policy uses :not_full_access. enforcement = :report does not relax either policy. Direct model writes and CurrentScope.grant! do not call this management predicate; host write paths must authorize their own callers.
Bulk console grants lock recipients before role and assignment rows. The recipient order is lexical by [subject.class.base_class.name, subject.id.to_s], so an integer id of 10 precedes 2. Host transactions that lock several recipients and then grant roles should use the same order to avoid lock cycles.
Role saves and direct permission-row writes lock the affected role. In a host transaction that changes several roles, lock all affected roles in ascending database id order before the first write; do not use name or input order. For example, use CurrentScope::Role.where(id: role_ids).order(:id).lock.load inside the transaction, after any recipient locks. A permission-row write counts as a write to its parent role for this ordering rule.
Compatibility validation can also acquire these role locks during valid? on Role, RolePermission, or ScopedRoleAssignment, even when no save follows. Inside an open host transaction, the database retains the locks until that transaction ends. Apply the same lock order before validation, and keep the transaction short; do not treat valid? as a lock-free preview.
Role deletion, definition import, and console mutations lock the entire role set in that same order. If a host transaction combines those operations with direct role or permission writes, acquire the entire role set first with CurrentScope::Role.order(:id).lock.load, after any recipient locks. Taking one role lock first and then requesting lower role ids can deadlock with a concurrent console write on databases with row locking.
Resource permission ceilings
current_scope_grantable_permissions limits the permission keys a role may carry when granted on a resource type. Include CurrentScope::Scopeable for picker support, or CurrentScope::GrantableRoles for the rule without browsing:
class Project < ApplicationRecord
include CurrentScope::Scopeable
self.current_scope_grantable_permissions = %w[projects#show projects#update]
end
A non-full-access role whose entire bundle is within this ceiling can be granted on a Project, including a newly created or renamed role. The declaration does not grant any permission by itself. Keys normalize to unique nonblank strings. A subclass inherits the declaration unless it supplies its own; nil means inherit, or no ceiling when no ancestor declares one. [] refuses every role. A nonempty ceiling accepts an empty role bundle, but always refuses full_access roles. If current_scope_grantable_roles also lists allowed role names, a scoped assignment write must satisfy both declarations. For existing holders, current_scope_grants_role_permissions?(role) checks the permission ceiling alone; the default name list does not block a role rename or safe bundle edit. Hosts that override current_scope_grants_role? retain their custom compatibility rule.
Scoped-assignment validation checks the saved bundle for an existing role and the proposed bundle for a new role before it is saved. Role bundle edits and direct role-permission writes also reject changes that exceed the ceilings of existing scoped holders. These are model validations, not database constraints. Adding a declaration does not rewrite or revoke existing grants; use bin/rails current_scope:report to identify incompatible assignments and remove or correct them. See Scopeable models for the name-based declaration and resource picker.
A draft record can inherit scoped permissions from its persisted parent before validation. It cannot match a direct scoped grant, even if an id was assigned in memory. Unsaved or destroyed parents terminate the chain. The child initiator veto and the prohibition on cascading full access remain in force.
Organization-role permission cache
Org-role permission bundles are loaded once per subject in a request or job. Normal role, permission, and assignment saves, destroys, and rollbacks clear this cache, so later resolver checks in the same operation see the current database state. Direct SQL and callback-skipping writes must explicitly call CurrentScope::Current.reset_org_role_cache before checking permissions again.
Batch authorization for one record
CurrentScope.resolver.allowed_subjects answers the same record-bound permission question for several subjects:
approvers = CurrentScope.resolver.allowed_subjects(
subjects: candidates, permission: "projects#approve", record: project
)
The signature is (subjects:, permission:, record:, actor: nil, cascade: true). Supply an ActiveRecord record instance; a class or nil raises ArgumentError. The returned array preserves input order, removes nil entries and duplicates, and contains only allowed subjects. It checks organization-wide, direct scoped and inherited grants using the same separation-of-duties decision as allow?. A scoped full-access role on an ancestor never grants access to a child, even when its bundle explicitly contains the requested key. An inherited grant requires a non-full-access role with that key. cascade: false skips ancestor grants while retaining organization-wide and direct grants.
An explicit actor: supplies the same real actor for every candidate’s separation-of-duties check; without it each candidate is evaluated as itself. The method queries current grants on each call and keeps no authorization snapshot. It does not add host account-status or workflow rules: filter those candidates in the host policy. Collection questions still use allow? or scope_for; they are not this method’s input shape.
Dev diagnostics
Three things this engine gets wrong silently, and silently in the bad direction: what went wrong looks exactly like what going right looks like. Each one now says so in the log.
| Flag | Fires when | Why you’d never notice otherwise |
|---|---|---|
warn_on_nil_sod_record | An SoD action was allowed while the gate had no record, so the veto was skipped | A veto that never ran looks identical to a veto that passed |
warn_on_inert_scoped_grant | Denied no_grant, the subject holds a scoped grant that would satisfy it, and the controller declares no current_scope_record | The 403 is byte-identical to “never granted”, so you go audit the grants — which are fine — instead of the controller, which isn’t |
warn_on_cross_controller_derivation | Short-form allowed_to?(:show, record) derived a different key than the gate on this controller enforces | If you meant this controller’s gate, the view and the gate disagree — and the symptom (a link that 403s, or a hidden one that works) shows up nowhere near the cause |
All three are log-only — no decision, exception, header, or audit row changes because of them, in any environment — and all three default on in development and test, off in production:
config.warn_on_nil_sod_record = Rails.env.local? # the defaults;
config.warn_on_inert_scoped_grant = Rails.env.local? # override either
config.warn_on_cross_controller_derivation = Rails.env.local? # way
The last one is a hint, not an accusation, and says so: asking about a different resource than the current controller handles derives a different key too, and that is correct and common. Nothing at the call site distinguishes the two, so it warns once per site and names both readings. The first two are unambiguous.
The default is the point. These catch mistakes you make while writing the app, which is exactly when dev/test is where you are — and a diagnostic that ships off is one the people who need it never find. warn_on_nil_sod_record has worked since v0.1 and defaulted off, which is how it helped nobody.
A fourth setting is a mode, not a flag — the opt-in GatingTripwire already speaks; the question is how:
config.gating_tripwire = Rails.env.local? ? :raise : :warn # the default
:raise (dev/test) makes CI go red on an ungated action; :warn (elsewhere) logs each ungated controller#action once, so a production host that included the mixin gets an inventory instead of 500s. There is no :off — not including the mixin is off.
Four loud-by-design behaviors. A controller excluded from the catalog can’t be granted, so gating it is a misconfiguration — Guard raises, names the matching excluded_controllers pattern(s), and tells you to either stop excluding it or skip_before_action :current_scope_check!. An unrouted controller#action raises a different message (not-routed, not excluded). A user_method that the controller doesn’t respond to raises instead of silently turning every request into a 403. And granting a permission key that isn’t in the catalog makes the role invalid, naming the key:
role.permission_keys = %w[reports#aprove] # typo
role.save # => false
role.errors[:permission_keys]
# => ["not in the permission catalog: reports#aprove — check for typos, or use
# assign_permission_keys(..., scrub: true) to drop stale keys deliberately"]
A grant that vanishes is the worst kind of bug this library can have: the role looks right in the UI, the save succeeds, and the denial arrives later as an unexplained 403. So a key the app doesn’t route is an error, not a shrug — that covers typos, programmatic grants of unrouted keys, and the never-routed break-glass permission (which stays ungrantable; see #21).
There is one legitimate reason to drop a key silently: a controller was removed, so a role still holds keys that no longer route. That is named at the call site rather than assumed:
role.assign_permission_keys(keys, scrub: true) # stale keys dropped, no error
role.save!
role.permission_keys_change[:rejected] # => ["gone#index"] — log it if you want
The diff is computed on save, so read it after. scrub: takes literal true and nothing else — a stray truthy value must not be able to turn the strict path off.
scrub: is deliberately not reachable from permission_keys=, so form params and strong-params flows always take the strict path. The role editor is unaffected: its grid is built from routed actions, so everything it submits is already in the catalog, and a stale key is cleaned up transparently on save.