CurrentScope
⚠️ 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#actionpair is a permission. Add anOrdersControllerand 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::CurrentAttributesfrom 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.

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

| 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 ApplicationController — Context 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_actionfail-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
DashboardControllerthat rendersReports: pathdashboard, route keyreports), the short-formallowed_to?(:show, report)derivesreports#showwhile the Guard enforcesdashboard#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.
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_fornarrows. Both halves are yours to wire. The gate only decides whether#indexruns — it cannot filter a list you build withProject.all. If a scoped role ticks a collection key and that action doesn't callscope_for, the subject reaches the action and sees everything it queries. Gate a collection action for scoped roles only alongside ascope_forlist.
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 ifcurrent_scope_recordreturns nil on an SoD member action, the veto is skipped — an org-wide-granted subject (including the initiator) passes.nilis 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. = "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:
config.allow_sod_bypassis on, and- the record's host hook
current_scope_sod_bypassed?returns true, and - 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 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.
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. = %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.(keys, scrub: true) # stale keys dropped, no error
role.save!
role.[: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_methodis security-critical, not an optional extra. The entire act-as security model keys offactor != user. If you impersonate but leaveactor_methodunset,actorfalls back touser, so it all looks fine in manual testing while being silently inert: the read-only-while-impersonatingMutationGuardnever engages, the SoD:eitherveto 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: callingCurrentScope.record_impersonation_started!withactor_methodunset raises — that call is your declaration that impersonation is live, so a missingactor_methodthere is unambiguously a misconfiguration. (A host that impersonates without ever calling the boundary API gets no runtime signal — so setactor_methodwhenever 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
sign_in 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.


