Published from
docs/guides/adopting-in-an-existing-app.md. Edit that file — this page is generated when the docs site builds.
Adopting CurrentScope in an existing app
The quickstart in the README assumes a fresh app. This guide is for the harder, more common case: an app that already has users, authentication, controllers, and traffic — where “add the gem and go” isn’t an option, because the gate is fail-closed and nothing is granted yet.
Everything here is about one question: how do you turn this on without breaking things you can’t afford to break?
Start in report mode. Don’t cut over blind.
This is the whole guide in one line. Everything else is detail.
The gate denies anything not granted. On day one of a retrofit, nothing is granted — so mounting it means your controller suite goes red and your users get 403s. Nothing is misconfigured when that happens; it’s the gate working on an app nobody has granted anything in yet.
# config/initializers/current_scope.rb
CurrentScope.configure do |config|
config.enforcement = :report
end
Report mode makes the gate observe instead of refuse: it logs what it would have denied, records it, and lets the request through. Run your suite, or run the app; then read the gaps back:
bin/rails current_scope:report
Would-be denials still outstanding — 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 outstanding would-be denial(s) across 2 subject(s).
That list is your migration plan, in the shape of the role grid you need to build. Seed the roles it names, re-run the report until nothing is still ungranted, then set config.enforcement = :enforce. Every step is one line back.
What “empty” means here. The ledger is append-only, so a would-be denial stays listed after you grant it. The report therefore re-checks every recorded denial against your live grants and counts only the ones that would still be denied today. That count is the one that reaches zero, and a denial you cannot re-check (its subject is gone) is counted as outstanding, never as ready. A denial that names a record which no longer loads is a different case: the report prints it on a line of its own and leaves it out of the count, because the gate loads a target the same way the report does and so is never asked about a row it cannot load. If you soft-delete, that line has one exception, and step 5 of the rollout below states it.
The re-check asks with the same model: the gate used, taken from the controller’s current_scope_model hook and stored on the ledger row (#196). Without it the report would ask a stricter question than the gate and list every subject a scoped grant already admits, which reads as “grant these” and is the one reading that hands a whole controller to everybody. Rows recorded before the gem stored that model are re-checked without one, and the report counts them on a line of their own: do not grant a permission to clear those, exercise the action again in report mode and read the fresh row.
Report mode is an adoption ramp, not a way to run in production. It relaxes exactly one thing — “nobody has granted this yet”. It never lifts the separation-of-duties veto, and it never opens the management console. Enabling it in production logs a loud boot warning saying you are not enforcing authorization; that warning is there because the failure mode is quiet, and a temporary survey is exactly the kind of thing that quietly becomes permanent.
See the README’s Installation section (report-mode ramp) for the short version.
Declare how a subject is identified
Grants store a primary key. That key differs across environments. Before you seed Owner against an existing users table, declare the portable key:
# config/initializers/current_scope.rb
config.subject_identity = :email
# or [:name, :email]
# or CurrentScopeSubjectIdentity.new # bin/rails generate current_scope:identity
This is not config.subject_label. Label is the name on the subjects page and is allowed to fail soft. Identity is how resolve finds a person, and duplicate keys raise at boot.
bin/rails current_scope:identity:check
bin/rails current_scope:identity:setup IDENTITY=email SUBJECT=you@example.com
bin/rails current_scope:identity:setup IDENTITY=email SUBJECT=you@example.com WRITE=1
Dry-run is the default. WRITE=1 calls CurrentScope.grant! and is the only flag that writes. PLACEHOLDER=1 needs a create_placeholder! factory on the identity object; with one, a dry-run only names the stand-in, and PLACEHOLDER=1 WRITE=1 creates a row marked current_scope_placeholder outside production. Never invent a production subject.
Because identity:setup WRITE=1 writes grants, it needs the #151 grant column shape in place — run current_scope:repair_schema first if boot tells you to. identity:check reads only your users table and runs either way.
Put a unique index on the host column. For a :email or [:name, :email] identity, boot then proves uniqueness from the index and never scans the subject table; without one it groups that table on every boot. If you supply an identity object instead, its unique? is yours, and boot pays whatever it costs. Run current_scope:identity:check in CI when you want every duplicate listed. The engine does not migrate your users table.
Your authentication must run before the gate
Symptom: an anonymous visitor gets a blank 403 with X-Current-Scope-Reason: no_grant, instead of being redirected to sign in.
Cause: the gate is a before_action registered when you include CurrentScope::Guard. Callbacks run in the order they’re declared. If the gate is registered before your authentication callback, it runs first, sees a nil subject, and — being fail-closed — denies. nil is not “anonymous, let the auth layer handle it”; to a fail-closed gate, nil is “nobody granted this”.
Two correct orderings. Either works; pick the one that matches your app.
# 1. Declare authentication ABOVE the includes.
class ApplicationController < ActionController::Base
before_action :authenticate_user! # runs first
include CurrentScope::Context
include CurrentScope::Guard
end
# 2. Or prepend it, if the include order is out of your hands
# (a concern, an engine, a base class you don't own).
class ApplicationController < ActionController::Base
include CurrentScope::Context
include CurrentScope::Guard
prepend_before_action :authenticate_user! # jumps the queue
end
If you’re unsure which is running first, the symptom tells you: a blank 403 means the gate won; a redirect to sign-in means auth won, which is what you want.
Devise (and any mounted engine)
Devise’s controllers descend from your ApplicationController, so they inherit the gate — and the gate denies them, because nobody can be granted devise/sessions#create before they’ve signed in. You need both halves:
# config/initializers/current_scope.rb
CurrentScope.configure do |config|
config.excluded_controllers += [%r{\Adevise/}, %r{\Ausers/}]
end
# and on the controllers themselves
class Users::SessionsController < Devise::SessionsController
skip_before_action :current_scope_check!
end
Why both, since doing one and not the other is the usual mistake:
| You did | What happens |
|---|---|
| Excluded only | Guard raises. An excluded controller can’t be granted, so gating it is a misconfiguration and the engine says so loudly rather than 403ing forever. |
| Skipped only | Works, but the controller stays in the catalog: dead rows in the permission grid whose routed-action ticks mean nothing — the grid badges them “gate not run” (an unconditional skip is provable), but they’re still clutter that excluding removes. (One exception: with break-glass on, an injected bypass_sod cell on such a row is live — the grid marks it exempt.) |
| Both | Correct. Not gated, not offered. |
This generalises to any mounted engine whose controllers inherit from ApplicationController — Sidekiq’s web UI, ActiveAdmin, Blazer. If it renders through your base controller, it inherits the gate.
⚠️ skip_before_action inherits, and it fails OPEN
This is the one trap in this engine that fails open. Everything else here fails closed — unsure means refuse. This one is the opposite, and it’s silent.
class ApiController < ApplicationController
skip_before_action :current_scope_check! # for the health endpoint, you thought
end
class Api::OrdersController < ApiController
# ...and every action here is now ungated. Nothing said so.
end
The skip is inherited by every subclass, forever. The permission grid used to show those actions as grantable with nothing saying otherwise — the UI actively told you they were protected while ticking that box ticked nothing. The grid now badges a row “gate not run” when the callback chain proves the gate never runs there (a bare or inherited skip, or a controller that never included Guard). The badge is proof-only: a conditional skip (only:/except:) is unprovable statically and renders unmarked — the grid’s own hint says an unmarked row is not proof, and config.gating_tripwire = :warn catches that shape at runtime (below).
Safe patterns
Prefer skipping on leaf controllers — the ones nothing inherits from:
class SessionsController < ApplicationController # nothing subclasses this
skip_before_action :current_scope_check!
end
Or re-assert the gate in the subclass, if you must skip on a base:
class Api::OrdersController < ApiController
before_action :current_scope_check! # take it back
end
How to detect it
Three tools, from cheapest to deepest:
1. The grid badge — automatic. Open the role editor: any controller provably ungated is marked “gate not run”. No setup. It only marks what the callback chain proves — conditional skips render unmarked.
2. bin/rails current_scope:ungated — the same inventory as a command. No mixin, no deploy, no traffic; run it against your real app and read the list. Its output states its own limit and routes conditional skips to the tripwire.
3. GatingTripwire — the runtime net. Include it on the base you want verified, and run your suite:
class ApplicationController < ActionController::Base
include CurrentScope::Context
include CurrentScope::Guard
include CurrentScope::GatingTripwire # raises in dev/test; :warn elsewhere
end
It’s a separate after_action, so skip_before_action :current_scope_check! doesn’t skip it — it fires on the inherited-skip action, finds the gate never ran, and raises (the dev/test default) or logs once per controller#action (config.gating_tripwire = :warn, the default outside dev/test). Because it keys on whether the gate actually ran, it catches the shapes the static tools can’t prove — a conditional skip’s skipped action warns; its gated actions stay silent. That’s the concrete detection step, and it’s why running the suite with the tripwire on is worth doing once per rollout — and why :warn in production turns it into an inventory instead of a 500.
Mark genuinely-public actions so it doesn’t cry wolf:
class ApiController < ActionController::Base
include CurrentScope::GatingTripwire
current_scope_skip_tripwire! only: :health
end
The residual, stated honestly
#62 named three symptoms with one cause. Two are closed; one is open on purpose:
- “Rendered as grantable in the UI” — closed for everything provable. The grid badges bare skips, inherited skips, and never-included-
Guardcontrollers. The one grid residual: a conditional skip renders unmarked, because its answer is unprovable statically — that shape is caught at runtime by:warn(this PR) and gets a static third state in #75. - “Undetectable at runtime” — closed for any action that completes.
config.gating_tripwire = :warnmakes the tripwire a production inventory instead of a 500, andbin/rails current_scope:ungatedanswers statically with no deploy at all. The tripwire’safter_actionblind spot (below) still applies: a request halted by an earlierbefore_actionnever reaches it, so that shape stays a named residual, not a closed one. - “Ungated in production” — still true, deliberately. This is detection, not prevention: the skip still inherits and still fails open. What changed is that it can no longer be invisible. The declared-skip macro that would make intent explicit is #76.
Two honest limits survive: the tripwire is an after_action, so it can’t see an action that renders from a halted before_action (unchanged); and every static tool here marks only what it can prove. The mitigations above — leaf skips, re-asserting the gate — are still the right habits.
Two inventories, two different questions
These get confused, so: they are complementary, not alternatives.
| Answers | Where | |
|---|---|---|
bin/rails current_scope:ungated | “Which controllers provably never run the gate?” — static, from the callback chain | anywhere, no traffic, no mixin |
GatingTripwire | “Which actions run ungated?” — no gate at all, including shapes the static tools can’t prove | raises in dev/test; config.gating_tripwire = :warn logs once per action elsewhere |
config.enforcement = :report | “Which actions are gated but ungranted?” — the gate ran and would have refused | anywhere, logs |
Report mode is silent about an ungated controller, because the gate never runs to report anything. The tripwire is silent about a missing grant, because the gate ran fine. A retrofit wants both: the tripwire (or the ungated task) to find what you forgot to gate, report mode to find what you forgot to grant.
Coexisting with Pundit, CanCanCan, or Action Policy
You do not have to rip out your existing authorization to start. They occupy different seams and can run side by side:
- CurrentScope gates at the controller boundary — may this subject run this action at all?
- Your existing policies stay for record-level rules — the per-record predicates, the scopes — until you port them.
A workable order:
- Turn on
Context+Guardin report mode. Nothing changes for users, with three named exceptions: what report mode will not downgrade. - Seed roles from
current_scope:reportuntil nothing is still ungranted. The raw list never shrinks, because the ledger is append-only; the count the report re-checks against live grants is the one that reaches zero. - Flip to
:enforce. Both systems now run; the gate admits, your policies still decide records. - Port record rules incrementally:
authorize/policy_scopebecomeallowed_to?/scope_for. A scoped role (“Editor of Project #7”) is usually what an ownership predicate was approximating. - Delete the old policy once nothing calls it.
Nothing about steps 1–3 requires touching a single policy. That’s deliberate — the risky part of a migration isn’t the typing, it’s the window where you can’t tell whether you changed who can do what.
Tooling to do the mapping and prove equivalence before you cut over is tracked in #45.
Namespaced and hybrid apps: grants come in pairs
items#index and api/v1/items#index are independent permission keys. The catalog derives from routes, so the same resource reached through two controllers is two rows in the grid, and granting one grants nothing about the other.
If your app serves both HTML and an API over the same records, every role needs both ticked — or your API 403s for people who can use the web UI fine. This bites during a rollout because the HTML side is usually what gets tested first.
The same split affects short-form checks. allowed_to?(:show, item) derives items#show from the record’s route key, which is not what Api::V1::ItemsController’s gate enforces. See the README’s namespaced / custom-named controllers foot-gun — pass the explicit key when the two diverge:
allowed_to?("api/v1/items#show")
In development and test, config.warn_on_cross_controller_derivation logs a nudge when a derived key diverges from the gate on the current controller. It warns once per site, and it names both readings — asking about a different resource than the controller handles derives a different key too, and that’s correct. See Dev diagnostics.
A rollout ladder
Roughly in order. Steps 1–3 change nothing for users, with three named exceptions: see the three things report mode will not downgrade after the ladder.
- Install, mount, and set
config.enforcement = :report. Fix the callback ordering above; add your Devise/engine skips + exclusions. - Inventory the ungated surface. Add
GatingTripwireto your base in dev/test and run the suite. Fix what it finds — inherited skips first. - Inventory the ungranted surface. Exercise the app, then
bin/rails current_scope:report. Seed the roles it names. If any of your controllers use scoped grants for their list pages, declaredef current_scope_model = TheTypeon each — a scoped grant opens a collection gate only for the type the controller names. Then read the three things report mode will not downgrade below: none of them is fixed by granting, and all three land in live traffic. - Flip one namespace to
:enforce? You can’t — enforcement is global. What you can do is watchcurrent_scope:reportreach zero still-ungranted and flip once. If you want a narrower blast radius, roll outGuarditself one base controller at a time (include it onAdmin::BaseControllerbeforeApplicationController). - Flip to
:enforce. Before you do, check the one thing the report cannot tell you: does your authentication run before the gate? A request that reaches the gate with no subject resolved is downgraded in report mode and recorded nowhere, so it can never appear in the survey, and it is refused the moment you flip. A clean report plus an unauthenticated request reaching the gate is the shape that turns a rehearsal into an outage. Denials naming a record that no longer loads are reported on their own line and do not block the flip: the gate can never be asked about that record again. One exception. If you soft-delete (acts_as_paranoid,discard, or anydefault_scopethat hides rows), a hidden-but-live record is reported the same way, and a controller that reads it withunscopedwill still reach the gate. Check that line before you read it as clear. Keep the diagnostics on in dev/test — they’re on by default and they’re how the next mistake tells on itself. - Broaden
excluded_controllersonly deliberately. Every entry is a controller that can never be granted; that’s a decision, not a cleanup.
Three things report mode will not downgrade
Report mode lifts exactly one wall: “nobody has granted this yet”. These three are refusals about something else, so they survive it — and granting fixes none of them. Each names a different fix.
| What you see | Why report mode keeps it | The fix |
|---|---|---|
403, reason model_undeclared | The controller never declared the type its collection lists, so a scoped grant has nothing to bind to. Only no_grant is downgraded. | def current_scope_model = TheType on that controller. Watch the reason header and the dev nudge — there is no current_scope:report row for it. |
403, reason no_grant, on an SoD action | The blind spot: current_scope_record returned nil or a non-record, so the four-eyes veto never ran. The subject could be the person who raised the record and nobody asked. | Return the record from current_scope_record on that member action. The engine logs it, writes an access.sod_blind_spot row, and current_scope:report gives it its own heading. |
500, CurrentScope::ConfigurationError | An SoD-listed action reached a model with no current_scope_initiator, so the veto cannot be measured and the engine refuses to guess. This is the fail-loud contract, and it is the one that can look like an outage mid-survey. | def current_scope_initiator = <who raised it> on that model (nil exempts a record). Audit config.sod_actions against your models before turning report mode on. |
You do not have to audit that last one by hand. The engine logs a preflight warning naming every SoD action whose declared model cannot answer the hook — at boot in production and staging, on the first request in development, whose route set is lazy — and bin/rails current_scope:report lists both that set and the requests that actually raised (access.sod_initiator_missing rows). CurrentScope::SodPreflight.scan returns the same list programmatically (a Result with .rows; ask .blind? before reading an empty one as an all-clear).
The access.sod_initiator_missing rows need the ledger: config.audit on (the default) and the current_scope_events table migrated. Without those the raises still happen and are still logged, but nothing is recorded — so an empty section means “nothing recorded”, not “nothing found”.
Read the preflight list as a lead, not a verdict: it can only inspect controllers that declare current_scope_model, and it states that limit in its own output. Full detail in Limitations (A17).
See also
- README — Installation (report-mode ramp)
- Configuration — Dev diagnostics
- Checking permissions
- #62 — the skip-inheritance fail-open; its detection half shipped (grid badge,
current_scope:ungated,:warnposture), the fail-open itself remains by design (#76 is the declared-skip macro) - #45 — assisted migration from Pundit / CanCanCan / Action Policy