CurrentScope

Gem Version CI License: MIT Website Status: not production-ready

⚠️ Not production-ready

There are some known issues which are currently being worked on. This is not production-ready, but it is ready for experimentation and spiking, or whatever people want to do with it — just not yet for production.

This is an authorization library, so the bar is different: a bug here is a user seeing or doing something they shouldn't. The open work is tracked in the issue tracker, and several items are security-relevant — permission keys that can be dropped silently, advisory checks that don't consult the catalog, and gaps in the separation-of-duties veto. Each is being worked through with a written plan and an adversarial review pass.

Kick the tyres, build a spike, tell us what breaks. Don't put it in front of real users yet.

Website: davidteren.github.io/current_scope — overview, the resolver, and quickstart at a glance.

Authorization as data you edit in a UI, not rules you hardcode and redeploy — with one ambient context that makes allowed_to? work identically in controllers, views, and components.

CurrentScope is a mountable Rails engine. You add the gem, run the install generator, and get:

  • Permissions auto-derived from your routes. Every controller#action pair is a permission. Add an OrdersController and its actions appear in the permission grid with zero wiring.
  • Roles as rows, not classes. A role is a named, editable bundle of permissions — ticked cells on a controller × action grid. Change what "Reviewer" means without a deploy.
  • Scoped roles. The same role, attached to one specific record: "Editor of Project #7" grants nothing on Project #8.
  • An optional separation-of-duties veto. Off by default; opt in by listing actions. Once on, whoever initiated a record can never approve it — not grantable, not configurable in the UI, overrides even full access. A structural guarantee, not a preference.
  • Fail-closed resolution. No grant means denied. Everything is a permission, even the baseline things every signed-in user can do.
  • An ambient authorization context. The current subject flows through ActiveSupport::CurrentAttributes from the controller gate down to the smallest ViewComponent. The view can never disagree with the gate — they ask the same resolver.

The decision order, fixed:

1. SoD veto        → initiator? (opt-in, off by default)  DENY (overrides all)
2. full_access     → role grants everything, forever     ALLOW
3. org-wide role   → role's permission set includes it   ALLOW
4. scoped role     → a role held on THIS record          ALLOW
5. otherwise       → default deny

Screenshots

The mounted management UI at /current_scope — self-contained (no web fonts, no build step, CSP-safe), first-class light and dark themes.

Permission grid — one row per controller, CRUD action groups derived from your routes; ticked cells glow, a partial group reads as indeterminate.

Permission grid

Subjects — everyone who can hold a role, their one org-wide role, and any per-record scoped roles; server-side search across all subjects.

Subjects

Roles Members Events
Roles Members Events

Regenerate: CAPTURE_SCREENSHOTS=1 RAILS_ENV=test bin/rails test test/system/screenshots_test.rb

Installation

# Gemfile
gem "current_scope"
bin/rails generate current_scope:install
bin/rails current_scope:install:migrations && bin/rails db:migrate

Include the concerns in ApplicationControllerContext populates the ambient subject from your authentication, Guard gates every action behind its own controller#action permission:

class ApplicationController < ActionController::Base
  include CurrentScope::Context   # sets CurrentScope::Current.user from current_user
  include CurrentScope::Guard     # fail-closed gate on every action
end

Skip the gate where authorization doesn't apply (sign-in, webhooks):

class SessionsController < ApplicationController
  skip_before_action :current_scope_check!
end

Retrofitting an app that already has users

Retrofitting a real app? There's a full guide: Adopting CurrentScope in an existing app — callback ordering vs. your authentication, the Devise recipe, the skip_before_action fail-open trap, hybrid HTML+API grants, and a rollout ladder. The short version is below.

The gate is fail-closed, so the line you just added denies everything until grants exist. On a greenfield app that's invisible — you seed the Owner role and move on. On an app that already has controllers and traffic, it means your suite goes red and your users get 403s the moment you deploy, and the only way to discover what you should have granted is to break it and read the wreckage.

Don't cut over blind. Run in report mode first:

CurrentScope.configure do |config|
  config.enforcement = :report   # :enforce (default) | :report
end

The gate now logs what it would have denied and lets the request through, recording each one to the ledger. Exercise the app, or just run your suite — then read the gaps back out:

bin/rails current_scope:report
Would-be denials — grant these to stop them (most-denied first):

  Ada Lovelace — currently Member
      412x  reports#index
       38x  reports#export
  Grace Hopper
        7x  reports#approve

Total: 457 would-be denials across 2 subject(s).

That is your grant-seeding work, in the shape of the role grid you need to build: every subject who'd have been refused, what they were missing, and how badly. Seed the roles it names, watch the list empty out, then flip to :enforce. Each step is one line back, and nobody gets a 403 while you learn.

The rows are ordinary ledger events, so query them directly if you want something the task doesn't show:

CurrentScope::Event.where(event: "access.would_deny").pluck(:subject, :details)
# => [["gid://app/User/7", {"permission" => "reports#index", "reason" => "no_grant"}], ...]

Report mode is an adoption ramp, not an off switch — don't run production on it. It relaxes exactly one denial: nobody has granted this yet. Everything else still refuses:

Still enforced in :report Why it can't be relaxed
Separation-of-duties veto Lifting it lets an initiator really approve their own record — a fraud action executed, not a role gap surfaced.
SoD actions the veto couldn't run on If an SoD action is gated without a record, the veto has no initiator to measure and is skipped — so the refusal that comes back says "not granted", not "SoD approved". Report mode won't speak for a rule nobody asked, and still refuses. (config.warn_on_nil_sod_record surfaces the misconfiguration behind it.)
The management console It's where grants are made. An observation flag that opened it would be a privilege escalation.
Impersonation read-only gate Runs before the permission check and answers to its own rule.

The response carries X-Current-Scope-Reason: would_deny on anything report mode let through, so you can spot them in an integration test or a proxy log without reading the ledger.

Assumption #1: every controller descends from a Guard'd base. An action on a controller that never includes Guard (an API base, a hand-rolled ActionController::Base) is silently ungated — though no longer invisibly: the permission grid badges any controller provably ungated ("gate not run"), and bin/rails current_scope:ungated prints the same inventory as a command. To catch it at runtime, include the optional CurrentScope::GatingTripwire on the base you want verified — it fires after any action that didn't run the gate: raising in dev/test, or logging once per controller#action under config.gating_tripwire = :warn (the default outside dev/test; once per process per site — a concurrent first hit can rarely emit a duplicate line), so a production host can inventory its ungated surface without 500ing. It carries its own current_scope_skip_tripwire! marker for genuinely-public actions (you can't use skip_before_action :current_scope_check! on a controller that never defined that callback — it raises at class load):

class ApiController < ActionController::Base
  include CurrentScope::GatingTripwire
  current_scope_skip_tripwire! only: :health
end

It's an after_action, so it can't see an action that renders from a before_action (halted chain) — a strong aid, not total coverage. The grid badge and the ungated task mark only what the callback chain proves: a conditional skip (only:/except:) renders unmarked and is exactly what :warn exists to catch.

Bootstrap the first admin (the management UI needs a full-access subject to enter, so the first grant can't happen in the UI). One command:

bin/rails current_scope:grant SUBJECT_ID=1   # grants the full-access Owner role

or in db/seeds.rb:

CurrentScope.seed_defaults!            # Owner (full_access) + Member
CurrentScope.grant!(User.first)        # give the first user the Owner role

Then manage everything at /current_scope (full-access subjects only): the role grid, org-wide assignments, scoped grants.

Usage

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 keyallowed_to?("dashboard#show") — which removes the ambiguity. The short form is only guaranteed to match the gate when path segment == route key.

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. 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.
  • no grant (or no subject) → empty, fail-closed like the gate.

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, and it is flat: a scoped grant lists that record only (parent/child cascade is deferred). 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

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; the mixin only decides what shows up in the dropdown. current_scope_label is a plain instance method, so your own definition always wins over the default.

Separation of duties (opt-in)

Separation of duties is off by default — the engine's baseline is scoped RBAC, and many apps want nothing to do with four-eyes. Turn it on by listing the actions an initiator can never perform on their own record, and declare who initiated each record:

# config/initializers/current_scope.rb
config.sod_actions = %w[approve]   # empty by default → no SoD
class Report < ApplicationRecord
  def current_scope_initiator = requested_by
end

Once enabled, the veto fails loud, not open: if an SoD action reaches a record whose class doesn't define the hook, the resolver raises a ConfigurationError instead of silently permitting. Return nil from the hook to exempt a record type, or trim config.sod_actions.

An SoD-gated member action MUST return its record from current_scope_record. This is the one asymmetry to know: a present record with a missing initiator hook raises (above), but if current_scope_record returns nil on an SoD member action, the veto is skipped — an org-wide-granted subject (including the initiator) passes. nil is legitimate for collection actions, so the resolver can't tell the two apart and won't raise. Returning the record on member actions is therefore the load-bearing control. In development and test the gate logs a nudge whenever an allowed SoD action was gated with no record (config.warn_on_nil_sod_record, on by default there, off in production) — see Dev diagnostics.

With sod_actions empty (the default), the veto step is a no-op and the resolver is simply full_access → org-wide role → scoped role → deny. No model needs current_scope_initiator — the ConfigurationError above only fires for actions that are in sod_actions. sod_identity is moot; roles, scoped roles, scope_for, audit, and impersonation are unaffected.

By default (config.sod_identity = :either) the veto weighs two identities: the effective subject and the real actor behind an impersonated session. So an admin who initiated a report can't slip past the veto by approving it while impersonating someone else — impersonation can never approve your own record. Set :subject to weigh only the effective subject. The two are identical when nobody is impersonating (actor == subject), so v0.1 hosts see no change.

Break-glass override (allow_sod_bypass)

Sometimes a workflow needs a conditional self-approval — e.g. the owner or a trusted admin may approve their own request. You can express that in your app (a second approve_own permission plus a controller branch), but that pattern has one forgettable, security-critical step: recording the override in the audit ledger. Break-glass promotes the pattern into the engine so the audit cannot be forgotten.

Be honest about what this is: it converts separation of duties from a structural guarantee into an audited policy override. It's called break-glass, not SoD. Its legitimacy rests on three things, all enforced: it is off by default, privilege-gated, and always audited.

config.allow_sod_bypass     = true          # default false → the veto is absolute
config.sod_bypass_permission = "bypass_sod" # grantable, editable in the role grid

With it on, the veto is lifted for a record only when all three hold, re-checked live at decision time:

  1. config.allow_sod_bypass is on, and
  2. the record's host hook current_scope_sod_bypassed? returns true, and
  3. the record's initiator holds the bypass permission (bypass_sod).

Where the cell is. Break-glass is the one permission that isn't an action you can route, so the role grid gets it injected rather than derived: with allow_sod_bypass on, every controller that routes an action listed in sod_actions grows a bypass_sod column, blank elsewhere. Tick it on the row for that resource and the role can break the glass — the supported way to build a "trusted admin may self-approve" role without full_access, which would grant it implicitly along with everything else and defeat the point of a scoped trusted approver. Turn allow_sod_bypass off and the column disappears and the key stops being grantable: grantability follows the catalog, and the catalog follows the flag.

Holding bypass_sod on a flagged, self-initiated record is the authorization for the SoD action — the bypass grants the action, it doesn't merely lift the veto and then re-check for a separate approve grant. bypass_sod must not appear in sod_actions (it isn't an SoD action); the engine raises if it does, to prevent a re-entrant loop.

When a bypass lifts the veto, the engine records exactly one append-only sod.bypassed audit event at the enforcement gate (never on advisory allowed_to? checks) and sets X-Current-Scope-Reason: sod_bypassed on the response. A missing hook means "this type never breaks glass" — fail-closed, no error. Under impersonation (sod_identity = :either) the bypass checks the initiator's privilege, so impersonation can't launder it.

Host recipe (the engine ships the mechanism; these stay yours, exactly as impersonation ships plumbing + recipe, not endpoints):

# 1. A per-record flag column: add_column :invoices, :sod_bypass_requested, :boolean, default: false
# 2. The hook, reading that column:
class Invoice < ApplicationRecord
  def current_scope_initiator     = requested_by
  def current_scope_sod_bypassed? = sod_bypass_requested?
end
# 3. Gate WHO may set the flag on the same bypass_sod permission (a controller
#    branch or a policy) — the engine deliberately does not own that decision.

Prefer true SoD for genuine fraud control (contracts, pay runs) where no override should exist. Reach for break-glass only when a conditional, privileged, audited self-approval is the real requirement. Unlike allow_mutations_while_impersonating, there is no production env-gate — the feature is per-record, privilege-scoped, and audited-by-construction, so production is its intended home.

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

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 Retrofitting an app that already has users. 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 every authorization change 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).

Note on the !: despite the bang, Event.record! only guarantees raise-on-failure under :strict (and for a missing actor). In the default true mode a missing events table is a warn-once no-op, and under false every call silently returns nil — so a mutation-wrapping transaction does not roll back on a failed audit write unless you opt into :strict.

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.

Three loud-by-design behaviors. A controller excluded from the catalog can't be granted, so gating it is a misconfiguration — Guard raises and tells you to either stop excluding it or skip_before_action :current_scope_check!. 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.

Impersonation (act-as)

Current distinguishes the effective subject (current_scope_user — who the request acts as) from the real actor (current_scope_actor — who is actually behind it). They're the same person until an admin impersonates someone; then permission checks read the subject while attribution reads the actor. current_scope_actor falls back to the subject, so it's never nil and you never write a nil branch. impersonating? is the read-only-state signal for views (show a banner, disable destructive controls).

Point actor_method at the host method that returns the real actor:

# config/initializers/current_scope.rb
config.actor_method = :true_user

actor_method is security-critical, not an optional extra. The entire act-as security model keys off actor != user. If you impersonate but leave actor_method unset, actor falls back to user, so it all looks fine in manual testing while being silently inert: the read-only-while-impersonating MutationGuard never engages, the SoD :either veto can't fire, and every audit row is attributed to the impersonated subject instead of the real admin. The permission path can't detect this, but the boundary API can: calling CurrentScope.record_impersonation_started! with actor_method unset raises — that call is your declaration that impersonation is live, so a missing actor_method there is unambiguously a misconfiguration. (A host that impersonates without ever calling the boundary API gets no runtime signal — so set actor_method whenever you set up act-as.)

The host owns the act-as switch — CurrentScope only reads it. The recipe:

class ApplicationController < ActionController::Base
  include CurrentScope::Context
  include CurrentScope::Guard

  private

  # The real actor: always the signed-in account, never the impersonated one.
  def true_user = current_user

  # The effective subject: re-resolved from the session EVERY request, never
  # cached in Current (which is per-request and must not be trusted across
  # requests). Falls back to the real actor when not impersonating.
  def current_scope_user
    return true_user unless session[:impersonated_subject_id]

    User.find_by(id: session[:impersonated_subject_id]) || true_user
  end
end

Wire current_scope_user in as your user_method, or override the reader as above. Start and stop act-as through state-changing verbs (CSRF-protected), and authorize who may impersonate — this is a privilege escalation surface:

class ImpersonationsController < ApplicationController
  def create   # POST /impersonation
    head :forbidden and return unless allowed_to?(:create, controller: "impersonations")
    session[:impersonated_subject_id] = params.expect(:subject_id)
    redirect_to root_path
  end

  def destroy  # DELETE /impersonation
    session.delete(:impersonated_subject_id)
    redirect_to root_path
  end
end

Clear the impersonation on both sign-in and sign-out (session.delete(:impersonated_subject_id)) so an act-as session can never outlive the login that started it or bleed into the next one.

Impersonated sessions are read-only by default

An impersonated session can look, but not touch: with actor_method set, every non-GET/HEAD request is denied while a real actor stands behind a different subject — including the engine's own management UI (editing roles and grants is the highest-value surface to keep read-only). This gate is a separate before_action from the permission check, so it survives skip_before_action :current_scope_check! and runs first. Flip config.allow_mutations_while_impersonating = true to allow writes (at which point the SoD :either veto above becomes the observable line of defense).

Production refuses this flag by default. Letting a real actor write as the subject they impersonate is a privilege-escalation and audit-integrity risk, so config.allow_mutations_while_impersonating = true raises at boot in production unless you set CURRENT_SCOPE_ALLOW_PROD_IMPERSONATION_MUTATIONS in the environment. An unsafe deploy fails loudly instead of running silently insecure. development, test, and staging are unaffected — the flag works there with no env var. Assigning false (the default) never raises anywhere. The escape hatch exists for cases like a live public showcase whose whole point is demonstrating impersonated actions; a real production app should almost always leave impersonated sessions read-only.

Because it runs first, the endpoints that end an impersonation must opt out — your stop-impersonation, sign-out, and sign-in actions — or you could never turn act-as off (and sign-in could never clear it):

class SessionsController < ApplicationController
  skip_before_action :current_scope_mutation_guard!   # sign-in/out ends act-as
end

class ImpersonationsController < ApplicationController
  skip_before_action :current_scope_mutation_guard!, only: :destroy   # stop act-as
end

Denials carry a machine-readable reason on AccessDenied#reason, surfaced on the response as the X-Current-Scope-Reason header:

Reason Means
sod_veto the record's initiator can't perform a separation-of-duties action on it
no_grant nothing granted the permission — the default deny
model_undeclared a record-less deny that a scoped grant would have opened, had the controller declared current_scope_model
model_invalid current_scope_model was declared but returned something other than a concrete ActiveRecord class
impersonation_gate a mutation while impersonating, which is read-only
not_full_access the management UI, which only full-access subjects enter

Every denial routes through one method, so a refusal can't reach a client without its reason. A host denial is a bodyless 403 — the reason header is the signal, and the gem won't render into your app's response contract. The engine's own management UI is the exception: it renders a short page saying a full-access role is required, because the person reading that one is an admin looking at a browser.

View/gate disagreement is by design. allowed_to? is HTTP-ignorant: it still returns true for a permission the subject genuinely holds, even though the mutation gate will 403 the resulting non-GET click while impersonating. Drive read-only affordances off impersonating? — render a banner, disable or hide destructive controls — rather than expecting allowed_to? to hide them.

The audit boundary events for act-as (recording who impersonated whom, and when it stopped) land in a later unit — this section is the resolution plumbing only.

Current is request-scoped and does not flow into Active Job. When a job needs the subject or actor, pass GlobalIDs (or ids) as arguments and re-resolve inside perform — never read CurrentScope::Current from a job.

Testing your app

require "current_scope/test_helpers"

class ApproveButtonComponentTest < ViewComponent::TestCase
  include CurrentScope::TestHelpers

  test "renders for a reviewer" do
    with_current_user(users(:reviewer)) do
      render_inline ApproveButtonComponent.new(report: reports(:pending))
      assert_selector "button", text: "Approve"
    end
  end
end

with_current_user is for in-process unit/view/component checks. To test your own controllers behind the gate in a request or system spec, seed real grants with grant_role! / grant_scoped_role! — they persist assignment rows that survive the request cycle (which with_current_user cannot, since Context re-resolves the subject on every real request). They seed grants only; your app still signs the subject in through its own auth:

class ReportsAccessTest < ActionDispatch::IntegrationTest
  include CurrentScope::TestHelpers

  test "a reviewer can list but not destroy" do
    reviewer = users(:reviewer)
    grant_role!(reviewer, role: roles(:member))              # org-wide grant
    grant_scoped_role!(reviewer, role: roles(:viewer), record: reports(:q3))  # one record

     reviewer            # your app's own auth
    get reports_path
    assert_response :success
  end
end

CurrentAttributes resets around every request, job, and test — the ambient subject cannot leak between executions.

The showcase app

The engine has a full companion showcase — a standalone, deployable Rails 8.1 host app (Hotwire, ViewComponent, built-in auth) that dramatizes every mechanism end to end: a multi-domain anti-fraud gallery (payroll / contracts / expenses), one-click "act as", a guided "try to commit fraud → refused" walkthrough, the auto-derived permission grid, and the management UI. It lives in its own repository:

davidteren/current_scope_showcase

Run it locally alongside this engine (checked out as a sibling directory):

git clone https://github.com/davidteren/current_scope
git clone https://github.com/davidteren/current_scope_showcase
cd current_scope_showcase
bin/setup          # bundle (resolves the engine at ../current_scope), seed the DB
bin/rails server   # http://localhost:3000

Design notes

  • resources/DESIGN.md — the original design-concept capture (under the placeholder name "Grantwork").
  • docs/RESEARCH.md — the research behind the ambient context: Evil Martians / Vladimir Dementyev (palkan) on CurrentAttributes vs dry-effects vs explicit passing, and what this gem borrows from Action Policy.

License

The gem is available as open source under the terms of the MIT License.