Published from
docs/guides/impersonation.md. Edit that file — this page is generated when the docs site builds.
Impersonation (act-as)
See also: Concepts & glossary.
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):
Clearing the mutation guard alone is not enough. The two gates are separate before_actions, so skipping one leaves the other running: the impersonated subject usually holds no grant for sessions#destroy or impersonations#destroy, and the permission gate then 403s the very request that would end act-as. Skip both on these endpoints:
class SessionsController < ApplicationController
current_scope_skip_gate!(reason: "sign-in must run without a grant")
skip_before_action :current_scope_mutation_guard! # sign-in/out ends act-as
end
class ImpersonationsController < ApplicationController
# Scoped to :destroy on purpose — STARTING an impersonation stays gated.
# With only:/except: the grid still marks the row (the reason is a
# whole-controller annotation), but the skip runs.
current_scope_skip_gate!(reason: "stop act-as must not need the impersonated subject's grant",
only: :destroy)
skip_before_action :current_scope_mutation_guard!, only: :destroy # stop act-as
end
Denials raise CurrentScope::AccessDenied with stable accessors for branded 403 pages and error trackers (prefer #permission over parsing #message):
| Accessor | Meaning |
|---|---|
#permission | denied controller#action key — the stable API. Defaults to the positional message when permission: is omitted |
#message | StandardError message. Gem raise sites pass the key as both message and permission; they can diverge if a caller passes an explicit permission: keyword |
#reason | machine-readable cause (also on X-Current-Scope-Reason) |
#record | the record under decision when the gate had one; nil on collection / impersonation-gate denials |
#subject | effective subject when known |
| 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 default management policy requires an organization-wide full-access role |
management_denied | the configured management authorizer refused entry or the requested role/assignment operation |
Guard and MutationGuard denials route through one method (current_scope_denied), so by default a refusal on a Guard-wrapped controller gets its reason header (and the denial log line below). “By default” matters: a host rescue_from CurrentScope::AccessDenied registered after the include replaces that method, and with it the header and the log line (see the last example in this section). A host denial is a bodyless 403 by default — 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 overrides the body seam to explain either the default full-access requirement or a refusal by the configured management policy. A refused role-edit proposal retains the submitted form with an error. See Management authorization for the callback contract; it does not disable the impersonation mutation gate.
The engine also registers CurrentScope::AccessDenied → :forbidden in ActionDispatch rescue responses (only if the host has not already mapped that class), so a denial that escapes Guard (PORO re-raise, Context-only controller) is still HTTP 403, not 500. That path is status-only — no X-Current-Scope-Reason header and no denial log line unless something in your stack writes them. On the Guard path, rescued denials log one INFO line mirroring the header:
[CurrentScope] denied reports#approve (no_grant) → 403
INFO is intentional so production captures denials without raising the log level; high-volume anonymous probes will grow the log — filter [CurrentScope] denied if that is noise for your operators.
Branded host 403 — prefer the body seam so the header and log stay intact:
# Keeps X-Current-Scope-Reason + the denial log; only the body changes.
def current_scope_render_denied(reason)
render "errors/forbidden", status: :forbidden, locals: { reason: reason }
end
Need #permission / #record / #subject on the page? A host rescue_from registered after include CurrentScope::Guard wins and replaces current_scope_denied — set the header yourself (or you lose it), and note this example restores only the header; write your own log line if you need the denial telemetry:
rescue_from CurrentScope::AccessDenied do |e|
response.headers["X-Current-Scope-Reason"] = e.reason.to_s if e.reason
render "errors/forbidden",
status: :forbidden,
locals: { permission: e.permission, reason: e.reason, record: e.record }
end
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.