# CurrentScope
> A mountable Rails engine (Rails >= 8.1, < 9) for authorization: permissions
> auto-derived from controller#action routes, roles as editable data,
> per-record scoped roles, an opt-in separation-of-duties veto (initiator
> can never approve their own record), and one ambient context so
> allowed_to? works identically in controllers, views, and components.
> Fail-closed: no grant means denied. Status: Beta. Built for
> experimentation and report-mode pilots while remaining security items
> are hardened. Run :report, read bin/rails current_scope:report, then
> flip to :enforce. #116 closes after that bake.
Key facts agents get wrong without reading:
- The gate covers EVERY action; skip it on sessions/webhooks with
skip_before_action :current_scope_check! or sign-in breaks.
- Separation of duties is OFF by default (config.sod_actions = []).
- An SoD member action must return its record from current_scope_record,
or the veto is silently skipped.
- An SoD action on a model with no current_scope_initiator RAISES (500),
in report mode too — report mode downgrades a missing grant, never a
misconfiguration.
- config.actor_method is security-critical the moment the host impersonates.
- Retrofit existing apps through config.enforcement = :report first.
- config.subject_identity is the portable key (column, composite, or
identify/resolve object). config.subject_label is display-only. They
are not the same knob. Duplicate identity keys raise. resolve never
inserts. PLACEHOLDER=1 is refused in production.
This file concatenates the published Markdown twins, then README and UPGRADING highlights. Prefer llms.txt when you only need the index.
---
# Overview
Source: https://davidteren.github.io/current_scope/index.md
# CurrentScope
Authorization as data you edit in a UI, not rules you hardcode and redeploy.
Permissions come from your routes. A role is a row of ticked cells. One ambient
`allowed_to?` answers the same question in controllers, views, and components.
**Website:**
**Install:** `bundle add current_scope`
Beta. Built for experimentation and report-mode pilots while remaining security
items are hardened. The last gate to 1.0 is one real host on
`config.enforcement = :report`, then `:enforce`
([#116](https://github.com/davidteren/current_scope/issues/116)).
## What you get
- Permissions auto-derived from every `controller#action` pair
- Roles as editable rows on a controller × action grid
- Per-record scoped roles
- An opt-in separation-of-duties veto (initiator can never approve their own record)
- Fail-closed resolution: no grant means denied
- One ambient context via `CurrentAttributes`
## Resolver order
```
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, or an ALLOW
ancestor role that ticks the key
(opt-in; not scoped full_access)
5. record-less → no record: a scoped grant of the ALLOW
named type opens a listed collection read
6. otherwise → default deny
```
## Docs
Machine index: [llms.txt](llms.txt). One-file ingest: [llms-full.txt](llms-full.txt).
Every HTML page has a Markdown twin at the same path with a `.md` suffix.
- [Is it the right fit?](comparison.md)
- [Quickstart](quickstart.md)
- [Adopting in an existing app](adopting-in-an-existing-app.md)
- [Concepts](concepts.md)
- [Checking permissions](checking-permissions.md)
- [Separation of duties](separation-of-duties.md)
- [Security & production checklist](security-checklist.md)
- [Configuration](configuration.md)
- [Impersonation](impersonation.md)
- [Testing](testing.md)
- [Upgrading](upgrading.md)
- [For AI agents](ai-agents.md)
- [Limitations](limitations.md)
## Source
- [GitHub](https://github.com/davidteren/current_scope)
- [RubyGems](https://rubygems.org/gems/current_scope)
- [Showcase app](https://github.com/davidteren/current_scope_showcase)
---
# Is it the right fit?
Source: https://davidteren.github.io/current_scope/comparison.md
# Is CurrentScope the right fit?
Authorization is a decision you live with for years, so this page is written to
help you say **no** as easily as yes. It compares CurrentScope with the
libraries Rails teams actually choose between — Pundit, Action Policy,
CanCanCan, Banken and Oso — names the cases where each of the others is the
better answer, and ends with a short set of questions that point at one of them.
**The honest summary:** the others put your rules in code you deploy. Oso puts
them in a policy language. CurrentScope puts them in rows an administrator
edits. Everything below follows from that one trade.
---
## The trade, in one paragraph
Every Rails authorization library answers "may this subject do this thing?".
They differ in **where the answer lives** and **who is allowed to change it**.
Pundit, Action Policy, CanCanCan and Banken keep it in Ruby: a policy class, an
`Ability`, a loyalty. Changing what "Reviewer" means is a code change, a review
and a deploy — which is exactly right when a permission change deserves that
much ceremony. Oso moves the rules into a policy language (Polar) that can be
shared across services and languages. CurrentScope moves them into your
database: permissions are derived from your routes, a role is a row with ticked
permissions, and an administrator changes it in a mounted UI while the app is
running.
That is a real trade with real costs. Read the next two sections before the
table.
---
## Where CurrentScope earns its keep
- **Roles change often, and not by developers.** If "what Reviewer means"
changes monthly and each change is currently a pull request, the grid is the
whole point.
- **Access is per record, not per class.** "Editor of Project 7, and nothing on
Project 8" is a stored grant, not a condition you re-derive in every policy.
A model can opt into a parent chain so a role granted on a project also
answers for that project's reports, for the actions the role ticks, including
reports created after the grant.
- **Somebody has to be able to answer "who could approve this, and when did
that change?"** With `config.audit` on, every scoped grant, every revoke, and
every org-wide grant made through the management console or `CurrentScope.grant!` lands
in an append-only ledger. Direct model writes such as `RoleAssignment.create!`
are the exception; the configuration guide tables them.
- **A four-eyes rule that must not be negotiable.** The separation-of-duties
veto is checked before everything else and cannot be granted around, not even
by full access. There is one deliberate break-glass, and it takes three things
at once: `config.allow_sod_bypass = true`, a `current_scope_sod_bypassed?`
hook on the record, and an initiator who holds the `bypass_sod` permission.
With auditing on, the resulting allow is written to the ledger as such.
- **You are retrofitting a live app.** Report mode downgrades **a missing
grant** to a logged allow, so you read the list before you enforce. It is not
an off switch: the separation-of-duties veto still refuses, and the management
console answers to its own full-access check rather than the gate.
## Where one of the others is the better choice
- **The rule depends on the data, not the record.** "Approve only under ten
thousand", "only during business hours", "only in the caller's region".
Pundit, Action Policy, CanCanCan and Oso all express that directly, in Ruby or
Polar. CurrentScope has **no vocabulary for it** — its grid is
controller × action, and the SoD veto is the one attribute-ish rule it
ships. Do not plan to bend it into ABAC.
- **You are not only on Rails.** One policy across several services or
languages is what Oso is built for. CurrentScope is a Rails engine and nothing
else. Note that Oso now means Oso Cloud, a paid hosted service: the
open-source library was retired in 2024.
- **Your permissions are not shaped like your routes.** A right that spans many
controllers, or one screen holding several different rights, fits a policy
object better than a `controller#action` grid.
- **Every permission change should be a code review.** That is a legitimate
policy, and it is an argument for Pundit or Action Policy, not against them.
- **You want the smallest possible dependency.** Pundit is a convention and a
few hundred lines. CurrentScope brings tables, a mounted UI, an audit ledger
and a schema guard.
- **You need it certified for production today.** CurrentScope is
[beta](limitations.md): the last gate before 1.0 is one real application
running report mode and then enforcing
([#116](https://github.com/davidteren/current_scope/issues/116)).
---
## Side by side
Read the first two rows first; the rest are consequences of them.
| | CurrentScope | Pundit | Action Policy | CanCanCan | Banken | Oso |
|---|---|---|---|---|---|---|
| **Where a rule lives** | Rows in your database | A policy class per model | A policy class, with pre-checks | One `Ability` class per user | A loyalty class per controller | Polar policy files, evaluated by a hosted service |
| **Who changes it** | An administrator, in a screen, live | A developer, then a deploy | A developer, then a deploy | A developer, then a deploy | A developer, then a deploy | A developer, or a policy deploy |
| **The permission list** | Derived from your routes | The methods you define | The rules you define | The actions and subjects you name | The methods you define | The actions you name in the policy |
| **A grant on one record** | Stored, with an audit trail | You model and query it | You model and query it | Conditions in the `Ability` | You model it | First-class relationship facts |
| **Reaching a parent record** | Opt-in declared chain, up to five hops | Hand-written in the policy | Hand-written in the policy | Nested conditions | Hand-written | Rules over the relationship graph |
| **Filtering a list** | `scope_for` returns a relation | Policy scopes | Scoping rules | Rules converted to SQL | Not its focus | Filter queries from the engine |
| **Attribute rules** ("under 10,000") | **Not expressible** | Any Ruby | Any Ruby | Conditions on attributes | Any Ruby | A core strength |
| **Who-did-what ledger** | Built in, append-only | Bring your own | Bring your own | Bring your own | Bring your own | Centralised logs in the cloud product |
| **Safe rollout on a live app** | Report mode + a starter grid | Your own instrumentation | Your own instrumentation | Your own instrumentation | Your own instrumentation | Test and simulation tooling |
| **Four-eyes rule** | Structural veto, with one explicit break-glass | A condition you write | A condition you write | A condition you write | A condition you write | A rule you write |
| **Admin UI** | Mounted, included | None | None | None | None | In the cloud product |
| **Runs where** | In your app, on your database | In your app | In your app | In your app | In your app | A service you call |
| **Last release** (2026-09-01) | 0.5.1, beta | 2.5.2, Sep 2025 | 0.7.6, Jan 2026 | 3.6.1, May 2024 | 1.0.3, Jan 2019 | Ruby gem retired Jan 2024 |
*Written from the shape of each library rather than a feature audit of its
latest release.* The release row is the one thing here that goes stale on its
own, so it is dated; check RubyGems before you decide. Two entries need a word
of warning rather than a column: **Banken** has had no release since 2019, and
**Oso's** open-source library, the `oso-oso` gem included, is
[deprecated](https://github.com/osohq/oso) in favour of Oso Cloud, so choosing
Oso today means paying for a hosted service, not adding a gem. If something
here is out of date, please
[open an issue](https://github.com/davidteren/current_scope/issues) — a
comparison that flatters the author is worth nothing.
---
## Which one fits you?
Answer seven questions. Nothing is sent anywhere: this runs in your browser, and
the shorter table below works with JavaScript off.
### A shorter version, as a table
| If this is true of you | Then |
|---|---|
| Rules depend on record attributes or time, and you cannot express them as roles | **Action Policy** (or Pundit), or **Oso** for the richest rules |
| You need one policy across services or languages | **Oso** |
| Non-developers must change permissions without a deploy | **CurrentScope** |
| You need per-record grants, an audit trail and a four-eyes rule out of the box | **CurrentScope** |
| You want the smallest, most conventional Rails dependency | **Pundit** |
| You want policy objects with caching, testing and failure reasons built in | **Action Policy** |
| You already think in `can :read, Post` and want list filtering from the same rules | **CanCanCan** |
| Your authorization is per controller, not per model, and you want it tiny | **Banken**, but read the maintenance note above the table first |
| You cannot ship anything that is still in beta, or anything pre-1.0 | Not CurrentScope, and not **Action Policy** either: it is mature and widely used but has never cut a 1.0 (0.7.6 today) |
---
## If it fits: what adopting it actually costs
Honest estimates, from the shape of the work rather than a promise.
### Greenfield, or an app with no users yet
**Half a day to a day.** There is nothing to retrofit and no traffic to break.
Install, run the generator, migrate, bootstrap the first admin, tick the grid,
and gate your controllers. The
[quickstart](quickstart.md) is the whole path.
### An existing app with users
Plan this in four stages. The long pole is **stage 2**, and it is calendar time,
not developer time.
1. **Install and record (a day).** Add the gem, run migrations, set
`config.enforcement = :report` and `config.audit = true`. A request that
would have been refused **for want of a grant** is allowed through and
written to the ledger instead. Read that as "no big-bang cutover", not as
"nothing is refused": the separation-of-duties veto, the management
console's own full-access check, a mis-declared record hook and the
impersonation gate are outside this setting and still refuse. A request
with no signed-in subject is let through and recorded nowhere, which is why
sign-in has to be skipped explicitly (below).
2. **Bake (one to four weeks).** Let real traffic run. Month-end, quarter-end
and the annual job matter here: an action nobody performs during your bake is
an action nobody has granted, and it will fail the day someone runs it.
3. **Build the grid (a day or two).** `bin/rails current_scope:report` prints
what each subject was refused and still needs. It creates no roles: you read
the list, design the roles yourself, tick the grid, assign, re-run, and
repeat until the report is empty. The work it saves is finding out what is
missing, not deciding what a role should mean.
4. **Enforce (an hour, plus a watchful week).** Flip to `:enforce`. Keep the
ledger on; it is now your record of who was refused what.
### What makes it slower
- **Routes that are not permissions.** Health checks, webhooks and sign-in must
be skipped explicitly, or the gate locks out the very requests that establish
a subject.
- **Actions your bake never saw.** See stage 2. This is the single most common
reason a flip goes badly.
- **A subject that is not a simple `id`.** UUIDs and composite identities are
supported, but `config.subject_identity` is a decision to make deliberately.
- **Rules you thought were roles.** If a third of your `if` statements turn out
to depend on amounts or dates, that is the signal from the section above:
you needed a policy library, not a role grid.
---
## Still deciding?
- Read [Limitations](limitations.md) — it is the least flattering page on this
site, on purpose.
- Read [Concepts](concepts.md) for the resolver order that decides every
request.
- Install it in a branch, run report mode for a week, and look at what the
report says. That costs a day and tells you more than any comparison table.
---
# Quickstart
Source: https://davidteren.github.io/current_scope/quickstart.md
# Quickstart
> **Beta.** Built for experimentation and report-mode pilots while we
> harden the remaining security items. Adopt it, run
> `config.enforcement = :report`, and tell us what breaks. General
> availability is gated on one real-world enforce bake
> ([#116](https://github.com/davidteren/current_scope/issues/116)). Do not
> flip `:enforce` on a live host until you have read the
> [security checklist](https://davidteren.github.io/current_scope/security-checklist.html). Report anything on the
> [issue tracker](https://github.com/davidteren/current_scope/issues).
## 1. Add the gem and install
```ruby
# Gemfile
gem "current_scope"
```
```bash
bin/rails generate current_scope:install
bin/rails current_scope:install:migrations && bin/rails db:migrate
```
## 2. Include the concerns
`Context` populates the ambient subject from your authentication; `Guard`
gates every action behind its own `controller#action` permission:
```ruby
class ApplicationController < ActionController::Base
include CurrentScope::Context # sets CurrentScope::Current.user from current_user
include CurrentScope::Guard # fail-closed gate on every action
end
```
Your authentication must run **before** these concerns do — `Context` reads
`current_user` when its callback fires. If your auth is set up in a concern
or callback registered *after* these includes, the gate runs first, sees no
subject, and denies. The
[adoption guide](adopting-in-an-existing-app.md)
covers callback ordering against Devise and friends.
## 3. Skip the gate where authorization doesn't apply
**Do not skip this step.** The gate is fail-closed and covers *everything*,
including sign-in — mount it without this and nobody can log in:
```ruby
class SessionsController < ApplicationController
# Prefer the declared form so the role grid shows why the gate is off:
current_scope_skip_gate!(reason: "sign-in must run without a grant")
# bare skip_before_action :current_scope_check! still works (unexplained badge).
# If you use impersonation, also skip the mutation guard on sign-in/out:
skip_before_action :current_scope_mutation_guard!
end
```
A skipped controller is unprotected by the permission gate — supply your own
authorization where that matters, and read the
[security checklist](security-checklist.md) before shipping.
## 4. Existing app? Run report mode first
The fail-closed gate denies everything until grants exist. On a greenfield
app that's invisible. On an app with users and traffic, cutting over blind
means red suites and 403s. Report mode logs what it *would* have denied and
lets requests through:
```ruby
CurrentScope.configure do |config|
config.enforcement = :report # :enforce (default) | :report
end
```
Exercise the app or run your suite, then read the gaps back out — this is
your grant-seeding worklist:
```bash
bin/rails current_scope:report
```
Seed the roles it names, re-exercise the app, and flip back to `:enforce`
once newly exercised requests stop adding `access.would_deny` rows (the
report reads the append-only ledger, so historical rows do not clear) and
any `access.sod_blind_spot` entries are resolved.
Report mode is an adoption ramp, not an off switch: the SoD veto, the
management console, and the impersonation gate all still refuse. Retrofitting
a real app? There is a
[full adoption guide](adopting-in-an-existing-app.md)
— callback ordering vs. your authentication, the Devise recipe, and a
rollout ladder.
## 5. Bootstrap the first admin
The management UI only admits full-access subjects, so the first grant can't
happen in the UI:
```bash
# The id must exist on your configured subject_class ("User" by default).
bin/rails current_scope:grant SUBJECT_ID=YOUR_USER_ID
```
or in `db/seeds.rb`:
```ruby
# Creates the default Owner (full_access) + Member roles if missing, then
# grants Owner. Member starts with zero permissions until you edit it.
CurrentScope.grant!(User.first)
```
## 6. Manage everything at `/current_scope`
The role grid, org-wide assignments, and scoped grants — all editable data,
no deploys.
## Where next
- [Concepts](concepts.md) — the resolver order and the ideas behind it.
- [Separation of duties](separation-of-duties.md) — the anti-fraud veto
(opt-in; off until you enable it).
- [Security checklist](security-checklist.md) — read before shipping.
- [Configuration](configuration.md) — every knob, with defaults.
- [For AI agents](ai-agents.md) — copy-paste prompts that encode the
foot-guns.
---
# Adopting in an existing app
Source: https://davidteren.github.io/current_scope/adopting-in-an-existing-app.md
> Published from [`docs/guides/adopting-in-an-existing-app.md`](https://github.com/davidteren/current_scope/blob/main/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.
```ruby
# 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:
```bash
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:
```ruby
# 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.
```bash
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.
```ruby
# 1. Declare authentication ABOVE the includes.
class ApplicationController < ActionController::Base
before_action :authenticate_user! # runs first
include CurrentScope::Context
include CurrentScope::Guard
end
```
```ruby
# 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:
```ruby
# config/initializers/current_scope.rb
CurrentScope.configure do |config|
config.excluded_controllers += [%r{\Adevise/}, %r{\Ausers/}]
end
```
```ruby
# 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.
```ruby
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:
```ruby
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:
```ruby
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**:
```ruby
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:
```ruby
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-`Guard`
controllers. 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](https://github.com/davidteren/current_scope/issues/75).
- **"Undetectable at runtime" — closed for any action that completes.**
`config.gating_tripwire = :warn` makes the tripwire a production inventory
instead of a 500, and `bin/rails current_scope:ungated` answers statically
with no deploy at all. The tripwire's `after_action` blind spot (below)
still applies: a request halted by an earlier `before_action` never 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](https://github.com/davidteren/current_scope/issues/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:
1. Turn on `Context` + `Guard` in **report mode**. Nothing changes for users,
with three named exceptions:
[what report mode will not downgrade](#three-things-report-mode-will-not-downgrade).
2. Seed roles from `current_scope:report` until 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.
3. Flip to `:enforce`. Both systems now run; the gate admits, your policies still
decide records.
4. Port record rules incrementally: `authorize` / `policy_scope` become
`allowed_to?` / `scope_for`. A scoped role ("Editor of Project #7") is usually
what an ownership predicate was approximating.
5. 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](https://github.com/davidteren/current_scope/issues/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](checking-permissions.md)
— pass the explicit key when the two diverge:
```ruby
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](configuration-reference.md#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](#three-things-report-mode-will-not-downgrade)
after the ladder.
1. **Install, mount, and set `config.enforcement = :report`.** Fix the callback
ordering above; add your Devise/engine skips + exclusions.
2. **Inventory the ungated surface.** Add `GatingTripwire` to your base in
dev/test and run the suite. Fix what it finds — inherited skips first.
3. **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, declare
`def current_scope_model = TheType` on 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](#three-things-report-mode-will-not-downgrade)
below: none of them is fixed by granting, and all three land in live traffic.
4. **Flip one namespace to `:enforce`?** You can't — enforcement is global. What
you *can* do is watch `current_scope:report` reach zero still-ungranted and
flip once. If you
want a narrower blast radius, roll out `Guard` itself one base controller at a
time (include it on `Admin::BaseController` before `ApplicationController`).
5. **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 any `default_scope`
that hides rows), a hidden-but-live record is reported the same way, and a
controller that reads it with `unscoped` will 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.
6. **Broaden `excluded_controllers` only 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 = ` 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](limitations.md) (A17).
---
## See also
- [README — Installation (report-mode ramp)](https://github.com/davidteren/current_scope/blob/main/README.md#installation)
- [Configuration — Dev diagnostics](configuration-reference.md#dev-diagnostics)
- [Checking permissions](checking-permissions.md)
- [#62](https://github.com/davidteren/current_scope/issues/62) — the skip-inheritance fail-open; its detection half shipped (grid badge, `current_scope:ungated`, `:warn` posture), the fail-open itself remains by design ([#76](https://github.com/davidteren/current_scope/issues/76) is the declared-skip macro)
- [#45](https://github.com/davidteren/current_scope/issues/45) — assisted migration from Pundit / CanCanCan / Action Policy
---
# Concepts
Source: https://davidteren.github.io/current_scope/concepts.md
# Concepts
Authorization as data you edit in a UI, not rules you hardcode and redeploy.
Five ideas carry the whole engine.
## Permissions are your routes
Every `controller#action` pair *is* a permission. Add an `OrdersController`
and its actions appear in the permission grid with zero wiring. There is no
policy file to write and no permission constant to register — the catalog is
derived from what your app actually routes, so it cannot drift from the code.
## Roles are rows, not classes
A role is a named, editable bundle of permissions — ticked cells on a
controller × action grid in the mounted management UI. Changing what
"Reviewer" means is an edit, not a deploy. Granting a key the app doesn't
route makes the role invalid (naming the key), so a typo is an error at save
time, not an unexplained 403 later.
## Scoped roles bind a role to one record
The same role, attached to one specific record: "Editor of Project #7"
grants nothing on Project #8. The list-side companion is `scope_for` — it
answers the row-membership question ("*which* records can this subject
reach?") from the same roles and grants the gate reads, so an index page and
the per-record gate stay one source of truth. Row membership is not
per-action authorization: the SoD veto applies to record-targeted *actions*,
not list membership, so drive per-row affordances through
`allowed_to?(action, record)`.
## The resolver order is fixed
Every check — the controller gate, a view helper, a ViewComponent — asks the
same resolver, which answers in this order:
```
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, or an ALLOW
ancestor role that ticks the key
(opt-in; not scoped full_access)
5. record-less → no record: a scoped grant of the ALLOW
named type opens a listed
collection read (index by default)
6. otherwise → default deny
```
Step 4 also matches a grant on a declared parent when the child opted in
with `current_scope_parent`. Step 5 is why a scoped-only subject can reach
an index. A collection action names the type with `current_scope_model`.
The class form `allowed_to?(:index, Report)` names the type itself.
Listed reads take their answer from `scope_for` and open only when that
list is not empty, including rows reached through a declared parent.
Other record-less keys (for example `create`) need an explicit tick on
the named type; a scoped `full_access` grant does not open those. An
action in `config.sod_actions` never opens on this arm: the veto needs a
record. The
[record-less rules](checking-permissions.md#scoping-a-list-scope_for)
are the full treatment.
Two things to notice. The [separation-of-duties veto](separation-of-duties.md)
outranks `full_access` — an admin cannot self-approve. And the last line is
the posture: **no grant means denied**. Everything is a permission, even the
baseline things every signed-in user can do.
## One ambient context
The current subject flows through `ActiveSupport::CurrentAttributes` from
the controller gate down to the smallest ViewComponent — no `current_user`
threading, ever:
```ruby
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
```
Controller, view, and component ask the same resolver.
`CurrentAttributes` resets around every request, job, and test, so the
ambient subject cannot leak between executions.
**The honest caveat:** the short form's key derivation matches the gate only
when the controller's path segment ends in the record's route key. In a
controller whose path differs from the record's route key (a
`DashboardController` rendering `Report`s), prefer the explicit full key —
`allowed_to?("dashboard#show")`. The Guard stays authoritative either way,
so a mismatch is a display bug, not a bypass. Details in the
[Checking permissions](checking-permissions.md#checking-permissions--anywhere).
## Where the deep answers live
The published guides are the same files as `docs/guides/` in the repo:
- [Concepts & glossary](concepts-and-glossary.md)
- [Checking permissions](checking-permissions.md)
- [Impersonation](impersonation.md)
- [Configuration reference](configuration-reference.md)
- [Testing](testing.md)
The [README](https://github.com/davidteren/current_scope/blob/main/README.md)
is the in-repo narrative those guides expand.
---
# Concepts & glossary
Source: https://davidteren.github.io/current_scope/concepts-and-glossary.md
> Published from [`docs/guides/concepts-and-glossary.md`](https://github.com/davidteren/current_scope/blob/main/docs/guides/concepts-and-glossary.md). Edit that file — this page is generated when the docs site builds.
# Concepts and glossary
> Deeper narrative definitions also live in root
> [`CONCEPTS.md`](https://github.com/davidteren/current_scope/blob/main/CONCEPTS.md) (relationships, grant model, machinery).
> This page is the short front door for adopters and agents.
## Decision order
Every allow/deny question routes through one pure resolver, in this fixed order:
```
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, or an ALLOW
ancestor role that ticks the key
(opt-in; not scoped full_access)
5. record-less → no record: a scoped grant of the ALLOW
named type opens a listed
collection read (index by default)
6. otherwise → default deny
```
Step 4 also matches a grant on a declared parent when the child opted in
with `current_scope_parent`. Step 5 is why a scoped-only subject can reach
an index. A collection action names the type with `current_scope_model`.
The class form `allowed_to?(:index, Report)` names the type itself.
Without a type the grant is type-unbound and the gate stays closed
(reason `:model_undeclared`). The listed read still opens only when the
subject's scoped list is not empty, including rows reached through a
declared parent. Other record-less keys (for example `create`) need an
explicit tick on the named type; a scoped `full_access` grant does not
open those. An action in `config.sod_actions` never opens on this arm:
the veto needs a record.
No grant means denied. The gate enforces that answer before the action runs.
The scoped list narrows which records the action sees. For listed collection
reads (`index` by default) the gate derives admission from the list so gate and
list agree by construction.
## Glossary
**Subject**
The identity permissions resolve against. Usually a person. Distinct from the
actor when someone is impersonating.
**Subject identity**
The portable natural key for a subject (`config.subject_identity`): one
column, a composite list, or a host `identify` / `resolve` object. Default
is the primary key. Grants still store that primary key. Duplicate natural
keys raise. This is not `subject_label`.
**Subject label**
The display string in the management UI (`config.subject_label`). Fail-soft
and never used to find a subject.
**Actor**
The real account behind the request. Equals the subject unless impersonating.
Attribution and the audit trail follow the actor; permission checks follow the
subject.
**Effective subject**
The subject the request acts as (impersonated user when act-as is live). Same
as `current_scope_user` / the ambient subject.
**Org-wide role**
The single application-wide role a subject may hold. Its permissions apply to
every record of every type. At most one per subject (DB-enforced).
**Scoped role**
The same role concept attached to one record ("Editor of Project #7"). Grants
nothing on other records. A subject may hold many, on different records.
**`full_access`**
A role property that satisfies every permission without listing them. Org-wide
it means everything; scoped it means everything on that record (plus listed
collection reads of that type). Step 2 of the decision order.
**Permission key**
A `controller#action` pair. The route pair *is* the permission. The catalog of
grantable keys is derived from routes, not hand-maintained.
**SoD veto**
Optional four-eyes rule: the record's initiator can never perform listed
member actions on their own record. Not grantable in the UI. Overrides even
`full_access`. Which identity counts is `config.sod_identity`: the default
`:either` weighs the effective subject **and**, while impersonating, the real
actor — so an initiator cannot approve their own record by acting as someone
else. Under `:subject` only the effective subject is weighed, which reopens
that impersonation path. See
[Separation of duties](separation-of-duties-and-break-glass.md).
**Initiator**
Who raised the record (`current_scope_initiator`). The identity SoD measures
against. Declared per model by the host.
**Break-glass**
An audited, privileged waiver of the SoD veto (`allow_sod_bypass`). Not SoD
itself. Requires config, a record flag, and an initiator-held permission. See
[Break-glass](separation-of-duties-and-break-glass.md#break-glass-override-allow_sod_bypass).
**Fail-closed**
Anything not granted is denied. Misconfiguration should fail loud, not look like
success.
**Ambient context**
Subject (and actor) carried on `ActiveSupport::CurrentAttributes` so
`allowed_to?` is identical in controller, view, and ViewComponent.
**Gate**
The enforcement point (`Guard`) that refuses an action unless the resolver
allows it. Admits or refuses; does not narrow lists.
**Scoped list**
`scope_for` — the records a subject may act on. Advisory unless the host uses
it (except collection-read actions, where the gate derives from it).
**Ledger**
Append-only history of authorization *changes* (grants, roles, impersonation
boundaries, lifted vetoes). Not a log of every allow/deny decision.
## Related guides
- [Checking permissions](checking-permissions.md)
- [Separation of duties and break-glass](separation-of-duties-and-break-glass.md)
- [Impersonation](impersonation.md)
- [Configuration reference](configuration-reference.md)
- [Testing](testing.md)
- [Adopting in an existing app](adopting-in-an-existing-app.md)
---
# Checking permissions
Source: https://davidteren.github.io/current_scope/checking-permissions.md
> Published from [`docs/guides/checking-permissions.md`](https://github.com/davidteren/current_scope/blob/main/docs/guides/checking-permissions.md). Edit that file — this page is generated when the docs site builds.
# Checking permissions
> See also: [Concepts & glossary](concepts-and-glossary.md).
## 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:
```ruby
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 `Report`s: 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 key** — `allowed_to?("dashboard#show")` — which removes the
> ambiguity. The short form is only guaranteed to match the gate when path
> segment == route key.
> **Residual foot-gun — `allowed_to?` never consults the catalog.** The Guard
> raises if you gate an uncataloged key. Advisory `allowed_to?` does not: a
> typo (`allowed_to?("reprots#show")`) is a silent false (button never shows);
> a raw grant row for a removed route can make `allowed_to?` true for a key the
> gate will never see. Fail-closed either way, but a debugging wall. Prefer
> keys that appear in the permission grid; treat the Guard as authoritative.
> (#36)
```ruby
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. Both sides key grants on
`polymorphic_name` (the stored token), so a custom type name cannot pass the
record check and miss the list. 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:
```ruby
# 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, plus
the children of granted parents when the model declares a chain (below).
- **no grant** (or no subject) → empty, fail-closed like the gate.
### A grant on a parent record (#108)
Scoping is flat by default: a role held on `Project 7` matches actions on
`Project 7` and nothing else. Opt a model in when authority should reach down a
level:
```ruby
class Report < ApplicationRecord
belongs_to :project
current_scope_parent :project
# Unchanged, and it still names the REPORT's requester.
def current_scope_initiator = requested_by
end
```
Now a scoped role held on `Project 7` satisfies `reports#approve` on that
project's reports, including reports created after the grant, and
`scope_for(Report)` lists exactly those reports.
Three things to know before you declare one.
**A scoped `full_access` grant does not cascade.** Only roles that explicitly
tick the key reach children. A scoped `full_access` grant on a root record would
otherwise open every permission on everything beneath it, which is a much larger
grant than the operator who ticked one box intended. The side effect is that
privilege stops being monotonic here: a `full_access` role reaches *fewer*
records through a chain than a role that merely ticks the key. If you want
blanket authority over a subtree, tick the keys.
**The four-eyes veto still reads the record you handed back**, never an ancestor.
A lead holding a grant on `Project 7` still cannot approve a report they
requested themselves. This is the whole reason the chain feeds grant matching
only — the older workaround, handing the *parent* back from
`current_scope_record` so the grant would match, moved the record the veto reads
and silently blinded it.
**It is a class macro, not a method.** Every other `current_scope_*` hook is a
plain method you define. This one names an association instead, because
`scope_for` has to build a query from the foreign key and a method returning a
parent instance cannot give it one. Writing `def current_scope_parent = project`
raises rather than being ignored.
**Declaration errors raise; data never does.** A missing association, a
`has_many`, a polymorphic or scoped `belongs_to`, or a declaration on an STI
subclass all raise `ConfigurationError` at the macro with the fix named. A
custom association primary key is refused later by
`ParentChain.validate_declarations!` (on `to_prepare`, plus after initialize
when `config.eager_load` is true), because the check needs `reflection.klass`
and resolving that inside the macro breaks ordinary forward references — see
[UPGRADING.md](https://github.com/davidteren/current_scope/blob/main/UPGRADING.md) for when that pass does and does not see a
model. The *shape of your rows* is not a misconfiguration: a chain longer than
five hops, or a `parent_id` loop, stops the walk where it runs out, **denies**,
and logs one warning per model. It never raises, because a loop in the data is
two `UPDATE`s and must not turn a live request into a 500. If a subject is
missing access they should have, look for "current_scope_parent stopped walking"
in the log.
### When a scoped grant reaches nothing
`bin/rails current_scope:report` and the role members view flag two shapes
before you flip `config.enforcement` to `:enforce`:
- **cannot match** — proven. The role ticks no permissions, or only keys that no
route produces. Tick a key, or remove the grant.
- **check hooks** — advisory. No ticked key names a controller for the grant's
record type. The engine cannot prove this (`current_scope_record` runs at
request time), so a controller serving that type under another name is a false
alarm. Read the hook before removing anything.
Neither is the same as **inert** (a grant whose record was deleted), which has a
third fix: remove the grant. See [Limitations A16](limitations.md).
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. A scoped grant lists that record only — plus the descendants of granted
records when the model declares `current_scope_parent` (see above). 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_action`s (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:
```ruby
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
```
The same hook has two foot-guns worth knowing before you ship: returning **nil**
on an SoD member action silently skips the veto
([Separation of duties](separation-of-duties-and-break-glass.md)), and loading with
`Model.find` means an unauthorized caller sees **403** for an existing id vs
**404** for a missing one — a record-existence oracle
([security checklist mitigation](security-checklist.md#2-403-vs-404-leaks-which-records-exist)).
## 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`:
```ruby
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, and once a record of such a type is linked the console will list that type's
records too, so a full-access operator can browse a table you never registered;
the mixin only decides what shows up in the dropdown unprompted. `current_scope_label`
is a plain instance method, so your own definition always wins over the default.
### Which roles may be granted on a type (#183)
A role's permission bundle is written for one **shape** of record. When that
record declares a `current_scope_parent`, a grant held on the CONTAINER resolves
for every record inside it — so granting a per-record role on the container hands
the subject that per-record surface across the whole container. One wrong pick in
a dropdown, and by default nothing objects.
A type can say what it accepts:
```ruby
class Workstream < ApplicationRecord
include CurrentScope::Scopeable
self.current_scope_grantable_roles = %w[Lead]
end
```
- **Absent a declaration nothing changes**: every role stays grantable on every
type. Opting in is per type.
- The rule is enforced on the **model**, so a seed, a rake task and a console
one-liner meet it as well as the management UI. It is a validation, so the
write paths that skip validations skip it too: `insert_all`, `upsert_all`,
`update_column` and `update_columns` write whatever they are given, and no
database constraint stands behind them. The picker narrows the type dropdown
to those that accept the chosen role, and says how many it withheld.
- An **empty** declaration, `self.current_scope_grantable_roles = []`, means no
role may be granted on that type. It is an assignment rather than a DSL call
precisely so a computed empty list declares the lockdown instead of silently
reading the current value. Assigning **`nil`** is the opposite: it means no
declaration, so a list read from config with a missing key leaves the type as
it was rather than locking it. "As it was" means the accept-everything default
on a plain class, and the **parent's** declaration on a subclass, which is the
next bullet's inheritance rule: `SpecialInvoice.current_scope_grantable_roles
= nil` under a locked-down base inherits that lockdown. Mind the difference when you compute
the list: `ENV.fetch("CS_ROLES", "").split(",")` is `[]` with the variable
unset, which is a lockdown, and that is the fail-closed answer. Assign `nil`
when you mean "no declaration".
- A subclass inherits its parent's declaration until it states its own, and an
**STI subclass** is governed by its own declaration even though the grant row
stores the base class's token. That needs the record: when the row it names is
gone, or its stored id is not a canonical key for the model, the check has
only the stored token to go on and judges the grant by the **base class**. One table therefore holds records with
different answers, so the picker keeps every class over an STI table in the
type dropdown and narrows the **record** list instead. How far the picker READS
is decided from `descendants`, which sees only loaded classes: in an
environment that does not eager-load, a declaring subclass nothing has
referenced yet can leave a grantable record past the display cut until the
next request, and production eager-loading closes it. What the picker SAYS is
decided from the rows instead, because a message that is wrong is worse than a
fetch that is narrow.
- `CurrentScope::GrantableRoles` can be included on its own if you want the rule
without the picker registration.
**Roles are matched by NAME**, and that is the trade a declaration written in
code makes: role ids are per-database and cannot be named in a model file. So
renaming a role stops the declarations that name it from matching, and a new role
that reuses the name inherits its acceptance. If you rename a role, grep for its
old name in your models, and run `bin/rails current_scope:report`: it lists the
grants whose type would refuse them today, which is where a rename shows up.
A rename is allowed through both the model API and the console, including when
the console submits an unchanged permission bundle. Existing grants remain in
place. Later bundle edits must stay within their resource permission ceilings;
the old name list does not prevent a safe bundle edit. A custom host
`current_scope_grants_role?` override still controls compatibility checks.
Adding a declaration does not rewrite or delete any grant already in the table.
It does apply the next time such a row is **saved**, though: the check runs on
every write, not only on create, so a pre-existing pairing the new declaration
refuses will fail validation if host code saves that row again. That is
deliberate. `assignment.update!(role: other_role)` has to meet the same rule as
the grant that created it, or the console's gate would be one `update` wide.
## Checking several subjects for one record
Use the resolver's batch method to build an approver picker without one grant
query per candidate:
```ruby
approvers = CurrentScope.resolver.allowed_subjects(
subjects: User.active,
permission: "reports#approve",
record: report
)
```
The method returns an array in candidate order. It uses the same separation-of-duties
check and scoped-grant relations as `allow?`. A persisted record or a draft with
persisted parents is supported. A nil record or model class raises `ArgumentError`;
use `allow?` and `scope_for` for collection decisions. Each call reads current grants
and retains no batch cache.
An optional `actor:` applies to every candidate. Omit it when listing independent
approvers. If one candidate is the effective user in an impersonated session, check
that candidate with its real actor separately. Host ownership, active-user, and
workflow rules remain the host policy's responsibility. `cascade: false` excludes
ancestor grants, as it does for `allow?`.
---
# Separation of duties
Source: https://davidteren.github.io/current_scope/separation-of-duties.md
# Separation of duties: the anti-fraud guarantee
**The person who initiated a record can never approve that same record.**
That is the whole rule. It is the classic four-eyes control: the employee who
files an expense claim cannot also approve the payout, the manager who drafts
a contract cannot also sign it off.
CurrentScope enforces this as a **veto at step 1 of the resolver** — before
any role or grant is even consulted:
```
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, or an ALLOW
ancestor role that ticks the key
(opt-in; not scoped full_access)
5. record-less → no record: a scoped grant of the ALLOW
named type opens a listed
collection read (index by default)
6. otherwise → default deny
```
Step 4 also matches a grant on a declared parent when the child opted in
with `current_scope_parent`. Step 5 is why a scoped-only subject can reach
an index. A collection action names the type with `current_scope_model`.
The class form `allowed_to?(:index, Report)` names the type itself.
Listed reads take their answer from `scope_for` and open only when that
list is not empty, including rows reached through a declared parent.
Other record-less keys (for example `create`) need an explicit tick on
the named type; a scoped `full_access` grant does not open those. An
action in `config.sod_actions` never opens on this arm: the veto needs a
record. The
[record-less rules](checking-permissions.md#scoping-a-list-scope_for)
are the full treatment.
Because the veto outranks everything, **a `full_access` admin still cannot
self-approve**. No role, no grant, and no tick in the management UI can lift
it. It is not editable in the permission grid. That is deliberate: a fraud
control that a sufficiently privileged person can switch off for themselves
is not a control. It is a structural guarantee, not a preference. (The one
deliberate exception is [break-glass](#break-glass-the-audited-override),
below — off by default, and a deploy-time decision, never a UI toggle. The
guarantee also assumes the defaults: `sod_identity = :either` and read-only
impersonation — weakening either is a deploy-time decision too, covered
under [impersonation](#impersonation-cannot-launder-an-approval).)
## It is OFF by default — you must opt in
This is the single most important fact on this page:
> **`config.sod_actions` defaults to `[]`. Empty means the veto never runs.**
> If you have not listed actions, you do not have separation of duties —
> no matter what else you configured.
The engine's baseline is scoped RBAC; many apps want nothing to do with
four-eyes, so it is opt-in by design. (It has been opt-in since v0.2 — v0.1
shipped it on by default, so if you upgraded from 0.1 without setting
`sod_actions`, your veto silently turned off. See
[Upgrading](upgrading.md).)
## Turning it on
Three declarations. First, list the actions an initiator may never perform
on their own record:
```ruby
# config/initializers/current_scope.rb
config.sod_actions = %w[approve] # matched on the ACTION segment of the key
```
Second, tell the engine who initiated each record, on the model:
```ruby
class Report < ApplicationRecord
def current_scope_initiator = requested_by
end
```
Third — and this one is load-bearing — the controller's
`current_scope_record` hook **must return the record on the SoD member
action** (the veto is skipped when the gate has no record; see the asymmetry
below):
```ruby
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
```
From here, when *any* subject whose identity matches the record's
initiator hits `reports#approve`, the resolver answers **deny** with reason
`sod_veto` — a 403 carrying `X-Current-Scope-Reason: sod_veto`.
### It fails loud, not open
If an action listed in `sod_actions` reaches a record whose class does not
define `current_scope_initiator`, the resolver raises a `ConfigurationError`
instead of silently permitting. Return `nil` from the hook to deliberately
exempt a record type, or trim `sod_actions`. With `sod_actions` empty this
error can never fire — no model needs the hook until you opt in.
**That raise is a 500, and `config.enforcement = :report` does not soften it.**
Report mode downgrades a missing grant; it never downgrades a
misconfiguration. So the engine tells you first instead: as soon as the route
set loads (boot in production and staging, the first request in development) it
logs a preflight warning naming every routed SoD action whose controller declares a
`current_scope_model` that cannot answer the hook, and a request that does
raise in report mode records an `access.sod_initiator_missing` ledger row —
provided the ledger is on: `config.audit` must be enabled (the default) and the
`current_scope_events` table must exist. Otherwise the raise still happens and
is still logged, but nothing is recorded, so an empty section means "nothing
recorded" rather than "nothing found".
`bin/rails current_scope:report` prints both, and
`CurrentScope::SodPreflight.scan` returns the boot list programmatically, as a
Result carrying `.rows`, `.inspected`, `.in_scope` and `.skipped`. Read it as a
lead, not a verdict — and empty `.rows` is not an all-clear by itself. Ask
`result.blind?`: true when a check failed (`.degraded?`) or when nothing was
read at all. Full limits: [Limitations A17](limitations.md).
### The one asymmetry you must know
**An SoD-gated member action must return its record from
`current_scope_record`.** 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**, and an org-wide-granted subject (including
the initiator) passes. `nil` is legitimate for collection actions, so the
resolver cannot tell the two apart and will not raise. Returning the record
on member actions is the load-bearing control.
In development and test the engine logs a nudge whenever an allowed SoD
action was gated with no record (`config.warn_on_nil_sod_record`, on by
default there). In report mode, a refusal the veto could not examine is
logged and recorded as a distinct `access.sod_blind_spot` ledger event, and
`bin/rails current_scope:report` lists these separately — granting a role
will not clear that 403.
### Collection actions in `sod_actions` are no-ops
The veto needs a **record** (to read the initiator). A collection action
legitimately has no record (`current_scope_record` returns `nil`), so listing
a bulk action in `sod_actions` — e.g. `approve_all` — does **nothing**. The
initiator can bulk-self-approve with no signal.
Do **not** treat "it's in `sod_actions`" as protection for bulk endpoints.
**Recipe for bulk endpoints:** gate the collection action with ordinary
permissions, then filter each record with the **member** SoD key:
```ruby
def approve_all
scope_for(Expense, permission: "expenses#approve_all").find_each do |expense|
# Explicit member key (not short-form) so namespaced controllers match the gate.
next unless allowed_to?("expenses#approve", expense) # honors SoD veto per record
expense.approve!(by: current_user)
end
end
```
Advisory `allowed_to?(:approve, expense)` consults the same veto as the gate,
including against `full_access`. That is the only supported way to keep
four-eyes on bulk work. (#29)
## How to verify it is live
Do not trust configuration reading; test the behavior:
```ruby
test "initiator cannot approve their own report" do
report = reports(:pending) # initiated by users(:ada)
grant_role!(users(:ada), role: roles(:approver)) # a role that ticks approve
sign_in users(:ada)
post approve_report_path(report)
assert_response :forbidden
assert_equal "sod_veto", response.headers["X-Current-Scope-Reason"]
end
```
If that test passes, the veto is running. If the response is 200, or the
reason is `no_grant` instead of `sod_veto`, SoD is not examining this action
— check `sod_actions` and the two hooks above.
## Impersonation cannot launder an approval
By default (`config.sod_identity = :either`) the veto weighs **two**
identities: the effective subject *and* the real actor behind an impersonated
session. An admin who initiated a report cannot slip past the veto by
approving it while impersonating someone else. Set `:subject` to weigh only
the effective subject; the two are identical when nobody is impersonating.
This requires `config.actor_method` to be set — without it, the engine cannot
see impersonation at all (see the
[security checklist](security-checklist.md)).
## Break-glass: the audited override
Sometimes a workflow genuinely needs a *conditional* self-approval — the
trusted owner may approve their own request. `config.allow_sod_bypass = true`
promotes that pattern into the engine so the one forgettable, security-critical
step — **recording the override in the audit ledger** — cannot be forgotten.
Be honest about what this is: it converts separation of duties from a
structural guarantee into an **audited policy override**. Its legitimacy
rests on three things, all enforced:
- **Off by default.** `allow_sod_bypass` defaults to `false`; the veto is
absolute until a deploy says otherwise.
- **Privilege-gated.** The veto is lifted for a record only when the flag is
on, the record's `current_scope_sod_bypassed?` hook returns true, **and**
the record's *initiator* holds the `bypass_sod` permission — a grantable
grid column that appears only on controllers routing an SoD action, and
only while the flag is on. Under impersonation the bypass checks the
**initiator's** privilege, so impersonation cannot launder it either.
- **Always audited.** Every lifted veto records exactly one append-only
`sod.bypassed` ledger event and sets
`X-Current-Scope-Reason: sod_bypassed` on the response.
**`full_access` holds the bypass.** A `full_access` role grants every
permission, including `sod_bypass_permission`. With break-glass on, every
full-access subject who initiated a flagged record can self-approve — the
population SoD usually targets. Prefer a **narrow role** that ticks only
`bypass_sod` (and the SoD actions they need), not full access, for trusted
break-glass holders. The README line "the veto overrides even full access"
describes the default (break-glass off); once break-glass is on, full_access
is an automatic bypass privilege. (Issue #29 covers both this full_access
interaction and collection no-ops above.)
`bypass_sod` must not appear in `sod_actions`; the engine raises at boot if
it does. 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.
## See it running
- The **[showcase app](https://github.com/davidteren/current_scope_showcase)**
dramatizes the veto end to end: a multi-domain anti-fraud gallery
(payroll / contracts / expenses), one-click act-as, and a guided
"try to commit fraud → refused" walkthrough.
- The **[04_sod_matrix scenario app](https://github.com/davidteren/current_scope_test_scenarios)**
is the adversarial test host that exercises every SoD knob: veto vs
`full_access`, missing and nil hooks, the break-glass matrix, and
impersonation × `sod_identity`.
## Full reference
The [SoD & break-glass guide](separation-of-duties-and-break-glass.md#separation-of-duties-opt-in)
is the canonical deep treatment — record-less refusals, the report-mode
blind spot, `sod_identity` modes, and the break-glass host recipe.
---
# SoD & break-glass
Source: https://davidteren.github.io/current_scope/separation-of-duties-and-break-glass.md
> Published from [`docs/guides/separation-of-duties-and-break-glass.md`](https://github.com/davidteren/current_scope/blob/main/docs/guides/separation-of-duties-and-break-glass.md). Edit that file — this page is generated when the docs site builds.
# Separation of duties and break-glass
> See also: [Concepts & glossary](concepts-and-glossary.md).
## 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:
```ruby
# config/initializers/current_scope.rb
config.sod_actions = %w[approve] # empty by default → no SoD
```
```ruby
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`.
That raise is a **500**, and `config.enforcement = :report` does not soften it —
report mode downgrades a missing grant, never a misconfiguration. So audit
`config.sod_actions` against your models before you enable it. The engine helps:
it logs a preflight warning **when the route set loads** (boot in production and
staging; the first request in development) naming every routed SoD action whose
controller declares a `current_scope_model` that cannot answer the hook, records
an `access.sod_initiator_missing` ledger row when a request does raise in report
mode, and lists both in `bin/rails current_scope:report`. The boot list is a
lead, not a verdict — it only sees controllers that declare
`current_scope_model`, and it states that limit in its own output.
> **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](configuration-reference.md#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 → record-less (listed reads via scope_for; other keys need an explicit tick) → 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.
> **Collection actions in `sod_actions` are no-ops.** The veto needs a record.
> Listing `approve_all` (or any collection action) does not enforce four-eyes
> on bulk work — the initiator can bulk-self-approve. Filter each row with
> `allowed_to?(:approve, record)` inside the bulk action. (#29)
>
> **Break-glass + `full_access`.** When `allow_sod_bypass` is on, a full_access
> role holds the bypass permission automatically. Prefer a narrow bypass role.
> See [UPGRADING.md](https://github.com/davidteren/current_scope/blob/main/UPGRADING.md) for the 0.1→0.2 SoD default flip.
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**.
```ruby
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 **at boot** if it does (and again at decision time as defense in
depth), so a re-entrant pairing fails the deploy instead of 500ing on the first
real break-glass attempt.
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):
```ruby
# 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.
---
# Security & production checklist
Source: https://davidteren.github.io/current_scope/security-checklist.md
> Published from [`docs/SECURITY-CHECKLIST.md`](https://github.com/davidteren/current_scope/blob/main/docs/SECURITY-CHECKLIST.md). Edit that file — this page is generated when the docs site builds.
# Security & going to production
> One findable checklist for the places CurrentScope's fail-closed promise can
> quietly become fail-open or leak information. Read this before shipping.
> Issue [#32](https://github.com/davidteren/current_scope/issues/32).
Related: [README](https://github.com/davidteren/current_scope/blob/main/README.md) · [Adoption guide](adopting-in-an-existing-app.md) ·
solid-solution worklist (S13).
---
## 1. Excluded + skipped = unprotected
**After `skip_before_action :current_scope_check!` on an excluded controller,
CurrentScope's permission gate enforces nothing on that controller.** The host
must supply its own authorization (`require_admin!`, Devise
`authenticate_admin!`, etc.). The impersonation `MutationGuard` is a separate
`before_action` and survives the gate skip: it still blocks non-GET/HEAD
requests while impersonating — provided `actor_method` is set (so impersonation
is detectable), `allow_mutations_while_impersonating` is false, and you have
not also skipped `current_scope_mutation_guard!`. Do not read the gate skip as
removing every CurrentScope safeguard, or the guard as authorization.
### How people get there
1. `config.excluded_controllers += [/\Aadmin\//]`
2. Gate a route under that path → loud `ConfigurationError` (good)
3. Follow the error's advice: `skip_before_action :current_scope_check!`
4. A signed-in user with **zero** CurrentScope grants still reaches the action
### Safe shapes
| Shape | OK? |
|---|---|
| Don't exclude — leave it in the grid and grant carefully | Yes |
| Exclude + skip **and** host auth on the same base controller | Yes |
| Exclude + skip only | **No** — BYO auth or you are open |
The inverse mistake (a controller that never included `Guard`) is what
`CurrentScope::GatingTripwire` and `bin/rails current_scope:ungated` catch.
---
## 2. 403 vs 404 leaks which records exist
The gate loads `current_scope_record` **before** deciding. That is deliberate —
scoped roles and SoD need the record. Side effect:
| Request | Unauthorized / no-grant caller sees |
|---|---|
| Member action, id exists | **403** (`no_grant`) — hook loaded the row, gate denied |
| Member action, id missing (hook uses `find`) | **404** — `RecordNotFound` inside the hook, before decide |
The missing-id row describes the `Model.find(params[:id])` hook shape. The
host owns `current_scope_record` — a hook that uses `find_by` (nil) or rescues
the lookup changes what a missing id returns; the engine does not guarantee
the 404.
An unauthorized caller can probe which ids exist. Anonymous requests also
trigger real DB loads for those ids.
**Most apps should keep real 404s.** For **sensitive** resources only, make
"forbidden" and "missing" indistinguishable:
```ruby
# Opt-in per sensitive controller / concern — not an engine default.
class SecretDocumentsController < ApplicationController
rescue_from ActiveRecord::RecordNotFound, with: :hide
# Later-registered handler wins for AccessDenied over the gem's rescue_from:
rescue_from CurrentScope::AccessDenied, with: :hide
private
def hide
head :not_found
end
end
```
Cost: you lose the machine-readable 403 reason (and legitimate users may see
404 on a real denial). That indistinguishability is the point — use it only
where enumeration is a threat.
Cross-link: nil-record SoD skip when this hook returns `nil` on an SoD member
action — [Separation of duties guide](separation-of-duties-and-break-glass.md#separation-of-duties-opt-in).
---
## 3. Foot-guns (pointers)
Do not re-derive these here — full treatments live in the README:
| Foot-gun | Where |
|---|---|
| SoD member action + nil record silently skips the veto | [SoD guide](separation-of-duties-and-break-glass.md#separation-of-duties-opt-in) · report-mode diagnosis (#73) |
| `actor_method` unset under impersonation | [Impersonation guide](impersonation.md#impersonation-act-as) |
| Short-form `allowed_to?` key drift on namespaced controllers | [README residual foot-gun](https://github.com/davidteren/current_scope/blob/main/README.md) (namespaced / custom-named controllers) |
| Advisory `allowed_to?` never consults the catalog | Issue #36 |
---
## 4. Going to production
Tick before you ship:
- [ ] **`config.audit = :strict`** if audit is mandatory — a missing
`current_scope_events` table then rolls the mutation back instead of
committing unaudited. Default `true` degrades gracefully for upgrades.
- [ ] **Bootstrap a full-access admin** on the fresh prod DB before opening the
console: `bin/rails current_scope:grant SUBJECT_ID=…` or `CurrentScope.grant!`.
The management UI only admits full-access subjects.
- [ ] **Leave `config.enforcement = :enforce`** (or finish report-mode survey and
flip). Report mode in production logs a boot warning for a reason — it is a
ramp, not a posture. `rails current_scope:report` (and `access.sod_blind_spot`
rows) only help while you are still surveying.
- [ ] **`allow_mutations_while_impersonating`** — production **raises at boot**
unless `ENV["CURRENT_SCOPE_ALLOW_PROD_IMPERSONATION_MUTATIONS"]` is truthy.
Prefer leaving it false so act-as stays read-only in prod.
- [ ] **Clear act-as on sign-in and sign-out** (and skip the mutation guard on
those actions) so a session cannot keep a foreign subject after auth churn.
- [ ] **`GatingTripwire` in dev/test** (`config.gating_tripwire = :raise` or
`:warn`) so ungated controllers fail the suite, not production traffic.
- [ ] **Hosts on `rails-html-sanitizer` ≥ 1.7.1** (engine lock may already pin
it; resolve your own tree).
- [ ] Read **excluded + skip** and **403/404** sections above for every
controller that is not a normal Guard'd HTML base.
---
## Denial surface (for host 403 pages)
See also [README denial reasons](https://github.com/davidteren/current_scope/blob/main/README.md) and issue #39:
- `AccessDenied#permission`, `#reason`, `#record`, `#subject` (prefer
`#permission` over `#message`; they match at gem raise sites but can diverge
if a host constructs the exception with an explicit `permission:`)
- Engine registers `CurrentScope::AccessDenied` → HTTP **403** in
`rescue_responses` only when the host has not already mapped that class
(escaped denials are not 500s; status-only — no reason header/log)
- Guard-path denials log at INFO:
`[CurrentScope] denied controller#action (reason) → 403` — filter if volume
from anonymous probes is noise
---
# Configuration
Source: https://davidteren.github.io/current_scope/configuration.md
# Configuration reference
Everything lives in `config/initializers/current_scope.rb`, created by the
install generator. The
[generated initializer](https://github.com/davidteren/current_scope/blob/main/lib/generators/current_scope/install/templates/initializer.rb)
and
[`Configuration`](https://github.com/davidteren/current_scope/blob/main/lib/current_scope/configuration.rb)
are canonical — each knob's full contract is documented there, in the source
a mismatch cannot hide from. This page is the map. The narrative reference
is the [configuration guide](configuration-reference.md); role YAML is
[portable role definitions](role-definitions.md); act-as is
[impersonation](impersonation.md).
## Identity
| Knob | Default | What it does |
|---|---|---|
| `user_method` | `:current_user` | Host method that returns the effective subject. A controller that doesn't respond to it raises (never a silent 403-everything). |
| `actor_method` | `nil` | Host method returning the **real** actor behind an impersonated session. **Security-critical the moment you impersonate** — unset, the mutation guard and SoD `:either` are inert and audit rows attribute to the impersonated subject. `record_impersonation_started!` raises if it's unset. |
| `subject_class` | `"User"` | The model that holds roles. |
| `subject_label` | `nil` | How the management UI names a subject: a Symbol (method), a Proc, or nil for best-effort (email → email_address → name → first+last → label). A label that raises degrades to the default chain and logs — it never breaks the page. Display-only. Not the portable key. |
| `subject_identity` | `nil` (primary key) | Portable natural key: a Symbol (`:email`), an Array of symbols (`[:name, :email]`), or an object with `identify` / `resolve`. Duplicate keys raise at boot. A String or Proc is rejected. Not `subject_label`. |
| `polymorphic_class_names` | `{}` | Optional token → class name map for a custom `polymorphic_name` Rails cannot reverse. Two classes may not share a token. The named class must actually store that token. |
## Enforcement
| Knob | Default | What it does |
|---|---|---|
| `enforcement` | `:enforce` | `:enforce` = a denial is a 403, the only production posture. `:report` = log missing grants and let requests through, recording `access.would_deny` ledger rows (audit must be enabled for the rows; the adoption ramp). Relaxes *only* "no grant" — SoD, the console, and the impersonation gate still refuse. Unknown value raises at boot. |
| `collection_read_actions` | `["index"]` | Record-less actions whose gate derives its answer from the scoped list (`scope_for`), so gate and list cannot disagree. Read-only names only — a full key raises; a **canonical** mutating name (`create`/`update`/`destroy`) warns loudly (a custom mutator name is not detected — do not add one). |
| `excluded_controllers` | rails/active_storage/action_mailbox/turbo/current_scope internals | Regexps excluded from the permission grid. Excluded controllers can't be granted, so they must also skip the gate — and are then **unprotected by CurrentScope** ([checklist § 1](security-checklist.md#1-excluded--skipped--unprotected)). |
| `parent_controller` | `"::ApplicationController"` | What the management UI inherits from (host auth and before_actions). |
## Separation of duties
Covered in depth in the [SoD guide](separation-of-duties.md).
| Knob | Default | What it does |
|---|---|---|
| `sod_actions` | `[]` | The opt-in switch. Empty = the veto never runs. Action names only (matched on the action segment); full keys raise. |
| `sod_identity` | `:either` | Which identities the veto weighs: `:either` = subject *and* real actor (impersonation can't launder an approval), `:subject` = effective subject only. |
| `allow_sod_bypass` | `false` | Break-glass. Off = the veto is absolute. On = liftable per record via three live-checked conditions, audited as `sod.bypassed` whenever `audit` is enabled (use `:strict` if an unaudited bypass must be impossible). |
| `sod_bypass_permission` | `"bypass_sod"` | The grantable break-glass permission. Must not appear in `sod_actions` — the engine raises at boot if it does. |
## Impersonation
| Knob | Default | What it does |
|---|---|---|
| `allow_mutations_while_impersonating` | `false` | Impersonated sessions are read-only: non-GET/HEAD denied while actor ≠ subject. Setting `true` **raises at boot in production** unless `CURRENT_SCOPE_ALLOW_PROD_IMPERSONATION_MUTATIONS` is set to a truthy value (`"1"`/`"true"` — `"false"`, `"0"`, and empty mean not opted in). |
## Audit
| Knob | Default | What it does |
|---|---|---|
| `audit` | `true` | Tri-state `false` \| `true` \| `:strict`. `true` records authorization changes (management UI, impersonation boundary, `grant!`) and degrades warn-once if the events table is missing; `:strict` raises instead, rolling the mutation back — the audit-mandatory posture. |
## Diagnostics (log-only, dev/test on by default)
All four default on in development and test, off in production, and never
change a decision, header, or audit row:
| Knob | Fires when |
|---|---|
| `warn_on_nil_sod_record` | An SoD action was allowed while the gate had no record — the veto was skipped. |
| `warn_on_inert_scoped_grant` | Denied `no_grant` while the subject holds a scoped grant ticking the key on *some* record (not necessarily the one this request targeted), and the controller declares no `current_scope_record` — the 403 looks like "never granted" while the likely fix is the controller hook. |
| `warn_on_cross_controller_derivation` | Short-form `allowed_to?` derived a different key than this controller's gate enforces. |
| `warn_on_undeclared_collection_model` | A request was denied `model_undeclared` — a record-less action whose controller names no `current_scope_model` while the subject holds a scoped grant that could open it (an explicit tick, or scoped `full_access` for a listed read whose type and live records match). Declaring the model is the usual one-line fix. The same knob gates the `model_invalid` nudge. |
| Knob | Default | What it does |
|---|---|---|
| `gating_tripwire` | `:raise` in dev/test, `:warn` elsewhere | How the opt-in `GatingTripwire` mixin speaks when an action ran without the gate: `:raise` makes CI go red; `:warn` logs each ungated `controller#action` once. No `:off` — not including the mixin is off. |
## Management UI
| Knob | Default | What it does |
|---|---|---|
| `permission_grid_groups` | read/create/update/destroy CRUD groups | How grid columns group actions per controller row. |
---
# Configuration reference
Source: https://davidteren.github.io/current_scope/configuration-reference.md
> Published from [`docs/guides/configuration-reference.md`](https://github.com/davidteren/current_scope/blob/main/docs/guides/configuration-reference.md). Edit that file — this page is generated when the docs site builds.
# Configuration reference
> See also: [Concepts & glossary](concepts-and-glossary.md).
## Configuration
Everything lives in `config/initializers/current_scope.rb` (created by the
install generator): the `user_method`, the `subject_class`, `sod_actions`,
`excluded_controllers` (keep infrastructure out of the grid), and
`parent_controller` (what the management UI inherits from).
`management_authorizer` optionally replaces the management console's default
full-access policy. The three
impersonation knobs — `actor_method`, `allow_mutations_while_impersonating`,
and `sod_identity` — are grouped in their own block and covered under
[Impersonation](impersonation.md); they layer in that order, so
`sod_identity` is only observable once a mutation is allowed past the read-only
gate.
Role **definitions** (name, description, `full_access`, permission keys) can
move between environments as YAML. That is not a config knob: see
[Portable role definitions](role-definitions.md). Assignments are not in
that document.
**`config.subject_identity`** — how a subject is identified for portable,
cross-environment use. Default `nil` is the primary key, so existing
installs change nothing. A Symbol names one column (`:email`). An Array of
symbols is a composite (`[:name, :email]`), stored as a list, never a
joined string. An object with `identify(subject)` and `resolve(key)` covers
a key split across tables. A String or Proc is rejected at assignment —
that shape is `subject_label`, which is display-only and fail-soft.
Identity is load-bearing: duplicate natural keys raise `ConfigurationError`
at boot (skipped during `db:` tasks, and skipped for the default primary
key). `resolve` returns nil when missing and never inserts. A blank identity
column raises too: `identify` refuses to mint a key that `resolve` could
never find. `CurrentScope.identify_subject(subject)` and `CurrentScope.resolve_subject(key)`
are the public entry points for the key itself: `identify_subject` returns the
portable key for a record, `resolve_subject` returns the record for a key in
this environment, or nil. `identify_subject` refuses a record that is not
`config.subject_class`, because `resolve_subject` only ever returns one of
those, so a key minted from another model would resolve to a different record.
**Duplicate detection follows your database's collation**, because it compares
your own identity columns. MySQL's default collation is case-insensitive, so
`Ada@example.com` and `ada@example.com` count as one duplicate key there and as
two distinct keys on PostgreSQL and SQLite. Each database stays self-consistent
(`resolve` matches under the same rules), but a host that tests on SQLite and
deploys on MySQL can meet a boot refusal that CI never showed. Run
`current_scope:identity:check` against a copy of production data, or use a
case-insensitive unique index, if that difference matters to you.
For a Symbol or Array identity, put a plain unique index on exactly those
columns and the boot check answers from the index, without scanning the
subject table.
Without one it is a grouping query over the subject table. An identity
OBJECT owns its own `unique?`, so boot pays whatever that method costs.
To list every duplicate, run `bin/rails current_scope:identity:check`. Guided attach:
`bin/rails current_scope:identity:setup IDENTITY=email SUBJECT=you@example.com`
(dry-run) then `WRITE=1` to call `grant!`. `PLACEHOLDER=1` needs a
`create_placeholder!` factory on the identity object from
`bin/rails generate current_scope:identity`; without one the task stops
with "PLACEHOLDER=1 has no factory". With a factory it writes a marked row
only together with `WRITE=1`, and only outside production. Never invent a
production subject.
**`config.subject_label`** is not the same knob. Label names a subject in
the management UI and is allowed to fail soft. Pointing both at `:email`
does not make the label a resolver.
**`config.polymorphic_class_names`** — optional Hash of stored type token to
class name, for a custom `polymorphic_name` that Rails cannot reverse. Default
`{}`. Auto-detected overrides (loaded models whose token is not the Rails
default name) merge with this map. Auto-detection sees only the models loaded
when the registry rebuilds: under `eager_load` (production) that is every model,
so the map is authoritative; in development a custom-token model autoloaded
after boot is not reverse-resolvable until the next reload. Map a token here when
it must resolve regardless of load timing. Two classes that claim the same token
raise at rebuild, including a shortened name that matches another loaded class.
The named class must actually store that token; an unknown class name raises.
A token is the stored grant identity: if you retire a model and later give its
token to a different class, existing grants rebind to the new class, so treat a
token rename or reuse as a data migration.
**`config.enforcement`** — `:enforce` (default) | `:report`. What the gate does
with a denial. `:enforce` means a denial is a 403; it is the only production
posture. `:report` logs a *missing grant* and lets the request through instead,
recording it as `access.would_deny` — the adoption ramp for retrofitting an
existing app, covered in
[Adopting CurrentScope in an existing app](adopting-in-an-existing-app.md)
and the short retrofit recipe in the README Installation section. It relaxes
nothing else: the SoD veto and the management console are untouched by it. An
unknown value raises at boot rather than being silently treated as one of the
two — believing you're enforcing when you aren't is the worst way to be wrong
about this setting.
**`config.collection_read_actions`** — `["index"]` by default. The record-less
actions whose gate derives its answer from the scoped list, so a scoped
`full_access` grant opens exactly the collections that would show its records
(gate and list agree by construction — the #65 fix). Set `[]` to restore the
pre-#65 behavior, where explicit ticks still open type-bound record-less gates
but scoped `full_access` opens none (the whole record-less family is new in
this release — no released version had either posture). A full key
(`"reports#index"`) raises at assignment (the list is action-segment matched,
app-wide), and a canonical mutating name (`create`/`update`/`destroy`) logs a
loud warning.
**List-narrowing reads only:** never name a mutating action here — that would
hand a scoped full_access holder the action on every record of the type off a
grant on one record. Custom read actions (`export`, `search`) are the intended
additions. Members normalize to strings on assignment, so `%i[index]` works.
The **audit ledger** is controlled by `config.audit` — tri-state
`false | true | :strict`. `false` records nothing; `true` (the default) records
authorization changes made through the **management UI**, the **impersonation
boundary**, and **`CurrentScope.grant!`** (including the rake task and seeds
bootstrap path — self-attributed, `details.source = "bootstrap"`), and degrades
gracefully (skip + warn once) if the events table isn't migrated; `:strict`
**raises** on a missing events table so an audit-mandatory app never commits an
unaudited change (the mutation rolls back).
Since #182 the ledger no longer depends on which door a GRANT was created or
destroyed through (updates are a separate matter — see the table below).
`scoped_role.granted`, `scoped_role.revoked`, `org_role.removed` and
`role.deleted` are emitted from model callbacks, so a seed, a rake task, a
console one-liner and the `grant_scoped_role!` test helper all record what the
management UI records. (`grant_role!` is a direct `RoleAssignment.create!`, and
org-role creation is one of the five writes named below as still unrecorded.)
Every event that CHANGES an authorization carries `details.attribution` —
`org_role.assigned`, `org_role.changed`, `org_role.removed`, `role.created`,
`role.updated`, `role.renamed`, `role.deleted`, `scoped_role.granted` and
`scoped_role.revoked`. It is `"actor"` when an ambient identity existed
(`Current.actor` answers `super || user`, so a request, a job, an ambient user
or `with_current_user` in a test all produce it), `"self"` when none did, in
which case the row is self-attributed to the record it is about.
`CurrentScope.grant!`'s rows are self-attributed too, so they read `"self"`;
what marks them as the bootstrap path is their separate `source: "bootstrap"`.
It is `attribution` and not `source` because `details["source"]` was already
taken, twice and for different things: `CurrentScope.grant!` writes
`source: "bootstrap"`, and `definitions.applied` / `definitions.rolled_back`
write the file path the document came from. Filter on `attribution` to isolate
who was behind an authorization change; filter on `source` for neither.
The OBSERVATION events carry no attribution and are not meant to:
`impersonation.started` / `.stopped`, `sod.bypassed`, `access.would_deny`,
`access.sod_blind_spot` and `access.sod_initiator_missing` record what happened
at the gate rather than a change to a grant. The two definitions events are
authorization changes and carry no `attribution` either — their actor is the
one the document was applied with. Do not read an absent `attribution` as
"not a human".
What is recorded from the model is **creation and destruction of scoped
grants**, and **destruction** of org-role assignments and of roles. Five write
paths are still silent, and it is worth knowing which before you rely on the
ledger:
| Write | Recorded? |
|---|---|
| `ScopedRoleAssignment` create / destroy | yes, from the model |
| `ScopedRoleAssignment#update!` direct (`role:`, `subject:`, `resource:`) | no — the callbacks are `after_create` and `after_destroy`; re-pointing a live scoped grant leaves no row |
| `RoleAssignment` destroy, `Role` destroy | yes, from the model |
| `RoleAssignment.create!` direct | no — `CurrentScope.grant!` is the documented path and carries the from/to a callback cannot see |
| `RoleAssignment#update!(role:)` direct | no — same reason; a console re-grant leaves no trail |
| `Role.create!` direct | no — `role.created` carries the initial permission set, which is not persisted when an `after_create` runs, and moving it to `after_commit` would forfeit the `:strict` rollback |
| `Role#update!` direct (`full_access`, `permission_keys`) | no — `role.updated` / `role.renamed` are emitted by the console |
So a privilege change made by `update!` in a console or a seed still leaves no
row. Use the management UI, `CurrentScope.grant!` or the definitions document
for changes that must be auditable.
UI events stamp `request_id` from `ActionDispatch::RequestId` via the Context
hook.
> **Note on the `!`:** despite the bang, `Event.record!` only guarantees
> raise-on-failure under `:strict` (and for a missing actor). In the 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`.
## Management authorization
**`config.management_authorizer`** defaults to `nil`: only subjects with an
organization-wide `full_access` role can enter or change the console. Set a
callable to delegate role administration under a host policy. It receives the
effective subject as its positional argument and `action:`, `role:` and
`target:` keywords. Only literal `true` permits the operation. A configured
callback also decides for full-access subjects; they do not bypass it.
```ruby
CurrentScope.configure do |config|
config.management_authorizer = lambda do |subject, action:, role:, target:|
RoleAdministration.allowed?(subject, action: action, role: role, target: target)
end
end
CurrentScope.can_manage?(:update_role, subject: editor, role: proposed_role)
CurrentScope.can_manage?(:assign_scoped_role, subject: editor, role: reviewer_role,
target: recipient)
```
`RoleAdministration` in this example is a host-defined policy. The engine does
not impose an administrator tier, a held-permission ceiling, or protected
recipient rules; the host must implement those requirements in its callback.
The callback does not replace application permissions or the impersonation
mutation gate. The console retains its last-full-access-holder protections.
| Action | Decision context |
|---|---|
| `:access` | Console entry; `role` and `target` are `nil`. |
| `:create_role`, `:update_role`, `:destroy_role` | The role being created, edited or deleted; `target` is `nil`. |
| `:assign_role`, `:revoke_role` | The organization-wide role and recipient. Clearing an absent assignment can supply `role: nil`. |
| `:assign_scoped_role`, `:revoke_scoped_role` | The scoped role and recipient. |
For scoped operations, `target` is the **recipient**, not the resource. The
callback receives no resource keyword. This API supports role- and
recipient-based delegation; it cannot express resource-specific console
administration rules. Resource-type compatibility is a separate declaration,
described below.
**Keep the callback a pure predicate.** It can run several times for one
request, including when rendering controls. Role updates check both the saved
role and a candidate carrying submitted attributes and permission keys. The
full-access checkbox checks a separate candidate with `full_access = true`,
even when the user has not selected it. Existing-role candidates keep their
persisted identity. Do not save candidates, emit audit events or consume a
quota from this callback; an authorization question is not a completed write.
`role` and `target` can be `nil`, including for unavailable assignment subjects.
**`CurrentScope.can_manage?`** accepts
`(action = :access, subject: CurrentScope::Current.user, role: nil, target: nil)`
and returns a boolean without performing the operation. A missing subject is
denied. With no callback it checks the subject's full-access role; otherwise it
calls the configured predicate. A non-callable setting raises
`CurrentScope::ConfigurationError` when checked. A configured-policy denial
uses `AccessDenied#reason == :management_denied`; the default policy uses
`:not_full_access`. `enforcement = :report` does not relax either policy.
Direct model writes and `CurrentScope.grant!` do not call this management
predicate; host write paths must authorize their own callers.
Bulk console grants lock recipients before role and assignment rows. The
recipient order is lexical by `[subject.class.base_class.name, subject.id.to_s]`,
so an integer id of `10` precedes `2`. Host transactions that lock several
recipients and then grant roles should use the same order to avoid lock cycles.
Role saves and direct permission-row writes lock the affected role. In a host
transaction that changes several roles, lock all affected roles in ascending
database `id` order before the first write; do not use name or input order.
For example, use `CurrentScope::Role.where(id: role_ids).order(:id).lock.load`
inside the transaction, after any recipient locks. A permission-row write
counts as a write to its parent role for this ordering rule.
Compatibility validation can also acquire these role locks during `valid?` on
`Role`, `RolePermission`, or `ScopedRoleAssignment`, even when no save follows.
Inside an open host transaction, the database retains the locks until that
transaction ends. Apply the same lock order before validation, and keep the
transaction short; do not treat `valid?` as a lock-free preview.
Role deletion, definition import, and console mutations lock the entire role
set in that same order. If a host transaction combines those operations with
direct role or permission writes, acquire the entire role set first with
`CurrentScope::Role.order(:id).lock.load`, after any recipient locks. Taking
one role lock first and then requesting lower role ids can deadlock with a
concurrent console write on databases with row locking.
## Resource permission ceilings
**`current_scope_grantable_permissions`** limits the permission keys a role may
carry when granted on a resource type. Include `CurrentScope::Scopeable` for
picker support, or `CurrentScope::GrantableRoles` for the rule without browsing:
```ruby
class Project < ApplicationRecord
include CurrentScope::Scopeable
self.current_scope_grantable_permissions = %w[projects#show projects#update]
end
```
A non-full-access role whose entire bundle is within this ceiling can be
granted on a Project, including a newly created or renamed role. The declaration
does not grant any permission by itself. Keys normalize to unique nonblank
strings. A subclass inherits the declaration unless it supplies its own;
`nil` means inherit, or no ceiling when no ancestor declares one. `[]` refuses
every role. A nonempty ceiling accepts an empty role bundle, but always refuses
`full_access` roles. If `current_scope_grantable_roles` also lists allowed role
names, a scoped assignment write must satisfy both declarations. For existing
holders, `current_scope_grants_role_permissions?(role)` checks the permission
ceiling alone; the default name list does not block a role rename or safe bundle
edit. Hosts that override `current_scope_grants_role?` retain their custom
compatibility rule.
Scoped-assignment validation checks the saved bundle for an existing role and
the proposed bundle for a new role before it is saved. Role bundle edits and
direct role-permission writes also reject changes that exceed the ceilings of
existing scoped holders. These are model validations, not database
constraints. Adding a declaration does not rewrite or revoke existing grants;
use `bin/rails current_scope:report` to identify incompatible assignments and
remove or correct them. See
[Scopeable models](checking-permissions.md#scopeable-models) for the name-based
declaration and resource picker.
A draft record can inherit scoped permissions from its persisted parent before
validation. It cannot match a direct scoped grant, even if an id was assigned
in memory. Unsaved or destroyed parents terminate the chain. The child initiator
veto and the prohibition on cascading full access remain in force.
## Organization-role permission cache
Org-role permission bundles are loaded once per subject in a request or job.
Normal role, permission, and assignment saves, destroys, and rollbacks clear this
cache, so later resolver checks in the same operation see the current database
state. Direct SQL and callback-skipping writes must explicitly call
`CurrentScope::Current.reset_org_role_cache` before checking permissions again.
## Batch authorization for one record
**`CurrentScope.resolver.allowed_subjects`** answers the same record-bound
permission question for several subjects:
```ruby
approvers = CurrentScope.resolver.allowed_subjects(
subjects: candidates, permission: "projects#approve", record: project
)
```
The signature is `(subjects:, permission:, record:, actor: nil, cascade: true)`.
Supply an ActiveRecord record instance; a class or `nil` raises `ArgumentError`.
The returned array preserves input order, removes nil entries and duplicates,
and contains only allowed subjects. It checks organization-wide, direct scoped
and inherited grants using the same separation-of-duties decision as `allow?`.
A scoped full-access role on an ancestor never grants access to a child, even
when its bundle explicitly contains the requested key. An inherited grant
requires a non-full-access role with that key. `cascade: false` skips ancestor
grants while retaining organization-wide and direct grants.
An explicit `actor:` supplies the same real actor for every candidate's
separation-of-duties check; without it each candidate is evaluated as itself.
The method queries current grants on each call and keeps no authorization
snapshot. It does not add host account-status or workflow rules: filter those
candidates in the host policy. Collection questions still use `allow?` or
`scope_for`; they are not this method's input shape.
## Dev diagnostics
Three things this engine gets wrong **silently**, and silently in the bad
direction: what went wrong looks exactly like what going right looks like. Each
one now says so in the log.
| Flag | Fires when | Why you'd never notice otherwise |
|---|---|---|
| `warn_on_nil_sod_record` | An SoD action was **allowed** while the gate had no record, so the veto was skipped | A veto that never ran looks identical to a veto that passed |
| `warn_on_inert_scoped_grant` | Denied `no_grant`, the subject **holds a scoped grant** that would satisfy it, and the controller declares no `current_scope_record` | The 403 is byte-identical to "never granted", so you go audit the grants — which are fine — instead of the controller, which isn't |
| `warn_on_cross_controller_derivation` | Short-form `allowed_to?(:show, record)` derived a **different key** than the gate on this controller enforces | If you meant this controller's gate, the view and the gate disagree — and the symptom (a link that 403s, or a hidden one that works) shows up nowhere near the cause |
All three are **log-only** — no decision, exception, header, or audit row changes
because of them, in any environment — and all three default **on in development
and test, off in production**:
```ruby
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:
```ruby
config.gating_tripwire = Rails.env.local? ? :raise : :warn # the default
```
`:raise` (dev/test) makes CI go red on an ungated action; `:warn` (elsewhere)
logs each ungated `controller#action` once, so a production host that included
the mixin gets an inventory instead of 500s. There is no `:off` — not including
the mixin is off.
Four loud-by-design behaviors. A controller excluded from the catalog can't be
granted, so gating it is a misconfiguration — Guard raises, names the matching
`excluded_controllers` pattern(s), and tells you to either stop excluding it or
`skip_before_action :current_scope_check!`. An unrouted `controller#action`
raises a different message (not-routed, not excluded). A `user_method` that the
controller doesn't respond to raises instead of silently turning every request
into a 403. And **granting a permission key that isn't in the catalog makes the
role invalid**, naming the key:
```ruby
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:
```ruby
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.
---
# Portable role definitions
Source: https://davidteren.github.io/current_scope/role-definitions.md
> Published from [`docs/guides/role-definitions.md`](https://github.com/davidteren/current_scope/blob/main/docs/guides/role-definitions.md). Edit that file — this page is generated when the docs site builds.
# Portable role definitions
Carry role names, descriptions, `full_access`, and permission-key bundles
between environments as one YAML document. Assignments (who holds which role,
on which record) are not in this document. That is issue #156 v2.
## Document
Identity is the role `name`. Roles are sorted by name. Keys are sorted inside
each role. `apiVersion` must be `current_scope/definitions-v1`.
```yaml
apiVersion: current_scope/definitions-v1
roles:
- name: Editor
description: Edits reports
full_access: false
permission_keys:
- reports#index
- reports#show
- name: Owner
description: ""
full_access: true
permission_keys: []
```
`full_access` must be `true` or `false`. There is no permissive cast, because a
typo in an authorization document must not grant full access. Unknown catalog
keys fail apply. Apply does not scrub. A YAML anchor, an alias, or a tagged
value is refused: the document is plain data.
## Commands
```bash
bin/rails current_scope:definitions:export FILE=config/current_scope/roles.yml
bin/rails current_scope:definitions:diff FILE=config/current_scope/roles.yml
bin/rails current_scope:definitions:import FILE=config/current_scope/roles.yml CONFIRM=1 ACTOR_ID=1
bin/rails current_scope:definitions:rollback SNAPSHOT=config/current_scope/roles.yml.pre.yml CONFIRM=1 ACTOR_ID=1
```
Import writes the pre-apply snapshot to `FILE.pre.yml`. Rollback reads
`SNAPSHOT=`, never `FILE=`. Passing the live document to rollback is a no-op.
## Confirm gate
- Production always needs `CONFIRM=1` (or `confirm: true` on the API), even
when the roles table is empty.
- Any other environment needs confirm once any role row exists.
- A TTY prompt is allowed only when stdin is a TTY and `CI` is unset.
Non-interactive callers pass the flag.
## Locks
Apply refuses a document that would leave zero org-wide full-access holders.
It also refuses to delete a role that still has org-wide or scoped holders.
An unassigned spare full-access role may be removed when another held
full-access role remains.
## API
```ruby
CurrentScope.export_definitions
CurrentScope.diff_definitions(yaml_or_path)
CurrentScope.import_definitions(yaml_or_path, confirm: true, actor: user)
CurrentScope.rollback_definitions(snapshot_path, confirm: true, actor: user)
```
Both take an optional `snapshot_path:`, the path the undo file is written TO.
Without it the undo file goes to `tmp/current_scope/last_definitions_snapshot.yml`.
When the document was read from a file and the undo file would land on that same
file, the undo file goes to `.pre.yml` instead. That is what makes a
rollback from `tmp/current_scope/last_definitions_snapshot.yml` safe: the
snapshot survives, so a second rollback is a no-op instead of re-applying the
change. An apply that does not commit puts the undo file back the way it was.
The read, write, and restore of that undo file take an exclusive lock on
`.lock`, so two overlapping applies that share a path cannot rewind
each other's undo point.
The audit event and the returned diff are computed after role locks. For an
existing role they name every permission key gained or lost, including a
concurrent edit that the desired-state document replaced. Adding or removing a
whole role is recorded by role name, not by listing that role's keys.
Rake tasks are thin wrappers. `ACTOR_ID=` looks up `config.subject_class` by
primary key, the same shape as `current_scope:grant` with `SUBJECT_ID=`.
---
# Impersonation
Source: https://davidteren.github.io/current_scope/impersonation.md
> Published from [`docs/guides/impersonation.md`](https://github.com/davidteren/current_scope/blob/main/docs/guides/impersonation.md). Edit that file — this page is generated when the docs site builds.
# Impersonation (act-as)
> See also: [Concepts & glossary](concepts-and-glossary.md).
## 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:
```ruby
# 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:
```ruby
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:
```ruby
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_action`s, 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:
```ruby
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](configuration-reference.md#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:
```ruby
# 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:
```ruby
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.
---
# Testing
Source: https://davidteren.github.io/current_scope/testing.md
> Published from [`docs/guides/testing.md`](https://github.com/davidteren/current_scope/blob/main/docs/guides/testing.md). Edit that file — this page is generated when the docs site builds.
# Testing your app
> See also: [Concepts & glossary](concepts-and-glossary.md).
## Testing your app
```ruby
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. A second org-wide
`grant_role!` for the same subject raises (one org role per subject) and names
`CurrentScope.grant!` for replace or scoped roles for additive access:
```ruby
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
# Assert the denial too — a test that only walks the allow path cannot
# tell you the gate is still closed on everything else.
delete report_path(reports(:q3))
assert_response :forbidden
end
end
```
`CurrentAttributes` resets around every request, job, and test — the ambient
subject cannot leak between executions.
---
# Upgrading
Source: https://davidteren.github.io/current_scope/upgrading.md
# Upgrading
The [CHANGELOG](https://github.com/davidteren/current_scope/blob/main/CHANGELOG.md)
is the canonical record of every release — read it top-down for the versions
you are crossing. Two changes deserve to be impossible to miss: one changes
your **security posture silently**, the other breaks programmatic callers
**loudly** (a 404). Unreleased: custom `polymorphic_name` tokens are matched
on lists and reverse-resolved through a closed map (see `UPGRADING.md`).
## 0.1 → 0.2: separation of duties became opt-in (silent)
`config.sod_actions` now defaults to `[]` — empty means
[the SoD veto](separation-of-duties.md) never runs. A 0.1 host that relied
on the old default and never set `sod_actions` explicitly **loses the veto
on upgrade with no error and no warning**. If you want SoD, say so:
```ruby
config.sod_actions = %w[approve]
```
**Checklist if you might be affected**
1. Do any models define `current_scope_initiator`?
2. Is `config.sod_actions` still empty (or unset) after upgrade?
3. If both yes: re-set `sod_actions` and re-run an initiator-cannot-approve test.
Root-repo copy: [UPGRADING.md](https://github.com/davidteren/current_scope/blob/main/UPGRADING.md)
([#27](https://github.com/davidteren/current_scope/issues/27)).
## 0.2 → 0.3: management-UI route rename (loud — programmatic callers 404)
Org-wide role assignment is now `resources :role_assignments` (plural).
A host that POSTs `/current_scope/role_assignment` (singular) or uses the
old `role_assignment_path` / `remove_role_assignment_path` helpers gets a
404 on upgrade. The engine's own UI is unaffected — this bites only direct
path or helper callers. Details in the
[CHANGELOG errata](https://github.com/davidteren/current_scope/blob/main/CHANGELOG.md).
## Also in 0.2+
- Declared Rails floor is `>= 8.1` (the management UI relies on
`params.expect` array semantics introduced in 8.1).
- `config.audit` became tri-state `false | true | :strict`.
## 0.3 → 0.4
Solid-solution Phase 1 (loud misconfig + audit honesty), denial ergonomics,
security checklist, docs site, migration toolkit. No intended host API break.
Boot **raises** if `sod_bypass_permission` is listed in `sod_actions` (#40).
## 0.4 → 0.5: run the migration, or the engine will not boot (security, #151)
**This release fixes a privilege escalation.** Grant ids were stored in integer
columns, so a model with a UUID (or any non-numeric) primary key had its key
truncated on write: `"7f00aaaa-…"` and `"7f00bbbb-…"` both became `7`. Two
subjects became one identity, and one inherited the other's org-wide role —
`full_access` included. If your subject or scoped-resource models use string
primary keys, you were affected on 0.2, 0.3 and 0.4.
```bash
bin/rails current_scope:install:migrations
bin/rails db:migrate
bin/rails db:test:prepare
```
The test database is a separate database with the same guard on it, so
`bin/rails db:test:prepare` is part of the upgrade, not an afterthought: skip it
and your next test run aborts at boot. That boot error names the database it
judged, and prefixes the repair it prescribes with `RAILS_ENV=test`, so it
points at the database that failed rather than the one you have just migrated. On MySQL, run `RAILS_ENV=test bin/rails
current_scope:repair_schema` as well: `db:test:prepare` builds the test database
from `schema.rb`, which may not carry the binary collation, so the guard refuses
there even though development is now correct.
The engine **raises at boot until that migration has run**, because a gem
upgrade alone would leave the escalation in place while every code path looked
correct. Database, installer and `assets:` tasks are exempt so the repair and
your asset build still work; anything that serves traffic or runs your code is
refused.
**If your database was built from `schema.rb`** — a new app, CI, a fresh
checkout — run this instead. `schema.rb` may not carry a MySQL collation, and
loading a schema marks every migration as already applied, so `db:migrate` has
nothing to do:
```bash
bin/rails current_scope:repair_schema
bin/rails db:test:prepare
```
Your test database is a separate database with the same guard on it, and
`db:test:prepare` reloads the same `schema.rb`. On MySQL, repair it too, or the
next `bin/rails test` still aborts at boot:
```bash
RAILS_ENV=test bin/rails current_scope:repair_schema
```
On MySQL, run that repair **before** any seeds that create grants:
`db:setup`/`db:reset`/`db:prepare` load `schema.rb` (which may leave the columns
case-insensitive) and seed in the same process, so a grant-creating seed is
refused until the collation is repaired. That refusal is the guard working, not a
bug. See `UPGRADING.md`.
Two things to know before you upgrade:
- **Rows written earlier are not repaired.** Once `"7f00aaaa-…"` was stored as
`7` the original value is gone. Those grants match nobody afterwards, so they
fail closed and must be re-granted. `UPGRADING.md` carries an audit query that
lists them, including the dangerous case where a truncated value landed on a
real record.
- **Grant ids now read back as strings for every host**, integer keys included:
`grant.subject_id == user.id` is now `false` where it used to be `true`.
Compare `.to_s` on both sides in your own code. This breaks host code
silently, with no exception and no log line: a tuple comparison against live
ids can delete every grant, and a hash keyed by `subject_id` misses every
lookup. `UPGRADING.md` lists the shapes to look for.
Full detail, including the MySQL collation and the 64-character limit, is in
[UPGRADING.md](https://github.com/davidteren/current_scope/blob/main/UPGRADING.md).
## Silent SoD and advisory footguns (any version)
These are not version flips; they are permanent posture facts:
- **Collection actions in `sod_actions` are no-ops** for the veto (no record →
no initiator). Do not list `approve_all`-style bulk actions and expect
four-eyes. Filter per record with `allowed_to?(:approve, record)` inside the
bulk action. Details:
[Separation of duties](separation-of-duties.html#collection-actions-in-sod_actions-are-no-ops).
- **`full_access` holds every permission**, including break-glass
`sod_bypass_permission` when break-glass is on. Prefer a narrow bypass role.
- **`allowed_to?` never consults the catalog.** Typo keys are silent false;
a raw stale grant row can be silent true. The Guard is authoritative.
---
# For AI agents
Source: https://davidteren.github.io/current_scope/ai-agents.md
# For AI agents (and the people driving them)
Increasingly the "reader" of authorization docs is an AI coding agent. These
prompts encode the foot-guns this documentation already knows about, so an
agent gets the integration right on the first try. Copy, adjust the names,
paste.
There is an [`llms.txt`](llms.txt) index of this site, a one-file
[`llms-full.txt`](llms-full.txt) corpus, and a Markdown twin for every HTML
page (same path, `.md` suffix, advertised as `rel="alternate"`). The deep
guides under `docs/guides/` are published on this site from that same tree —
do not treat raw GitHub as a second canon.
## Install CurrentScope in this app
```text
Add the current_scope gem to this Rails app (Rails >= 8.1, < 9 required).
1. Add `gem "current_scope"` to the Gemfile, then run:
bin/rails generate current_scope:install
bin/rails current_scope:install:migrations && bin/rails db:migrate
2. In ApplicationController, include CurrentScope::Context and
CurrentScope::Guard (in that order). Authentication must be wired
BEFORE these concerns run — Context reads current_user when its
callback fires, so an auth callback registered after these includes
means the gate denies before authentication happens.
3. The gate is fail-closed and covers EVERY action. You MUST add
`skip_before_action :current_scope_check!` to controllers where
authorization does not apply — sign-in/sessions (or nobody can log in),
webhooks, health checks. A skipped controller is unprotected by the
permission gate: keep or add the app's own auth there. If the app has
act-as/impersonation, ALSO add
`skip_before_action :current_scope_mutation_guard!` on sign-in,
sign-out, and the stop-impersonation action — that guard is a separate
callback that survives the gate skip, and without its skip an
impersonating admin cannot sign out or stop impersonating.
4. If this app already has users and traffic, set
`config.enforcement = :report` in the initializer BEFORE deploying,
run the test suite, then run `bin/rails current_scope:report` and seed
the roles it names. Flip back to :enforce only when re-exercising the
app adds no NEW access.would_deny rows (the report reads the
append-only ledger — historical rows never clear) and any
access.sod_blind_spot rows are resolved. Report mode must not be the
final state.
If config.sod_actions is set, ALSO clear any access.sod_initiator_missing
rows before flipping. Those are not denials — they are 500s. An SoD action
reaching a model with no current_scope_initiator RAISES
CurrentScope::ConfigurationError, in :report mode exactly as in :enforce,
so granting will not clear them. Best done before enabling SoD at all:
the engine logs a preflight warning naming the ones it can see (at boot in
production; on the first request in development, whose routes are lazy),
`bin/rails current_scope:report` lists them, and
`CurrentScope::SodPreflight.scan` returns them programmatically: a Result
with .rows ([permission, model_class] pairs), .inspected, .in_scope and
.skipped. An EMPTY .rows is NOT an all-clear on its own — check
`result.blind?`, which is true when a check failed (.degraded?) or when
nothing was read at all (.inspected == 0), because the scan only sees
controllers declaring current_scope_model.
5. Bootstrap the first admin (the management UI only admits full-access
subjects): `bin/rails current_scope:grant SUBJECT_ID=` — or, in
seeds, `CurrentScope.grant!(user)` (it creates the default
Owner/Member roles if missing; Member starts with zero permissions).
6. Declare record hooks as PRIVATE controller methods. For member actions:
`def current_scope_record = (set_thing if request.path_parameters[:id])`
— key off request.path_parameters, never params, so a ?id= query string
cannot smuggle a record into collection actions. Use the route's actual
member key: a route declared with a custom param (e.g. :slug) must
check request.path_parameters[:slug], or the hook returns nil and the
SoD veto is silently skipped on that action.
7. Run the full test suite. Expect controller tests to 403 until grants are
seeded — use the test helpers (grant_role!/grant_scoped_role!) from
current_scope/test_helpers, not stubs.
## Carry role definitions between environments
```text
Export and diff are safe for an agent to run. Import and rollback are not
autonomous in production.
1. Export live roles:
bin/rails current_scope:definitions:export FILE=config/current_scope/roles.yml
2. Commit the YAML. Diff against another environment:
bin/rails current_scope:definitions:diff FILE=config/current_scope/roles.yml
3. A human reviews the printed diff. The document is desired state for role
names, descriptions, full_access, and permission keys. It does not carry
assignments.
4. A human applies, never an agent on its own:
bin/rails current_scope:definitions:import FILE=config/current_scope/roles.yml CONFIRM=1 ACTOR_ID=
Production always needs CONFIRM=1. A populated non-production database
does too. Do not set CONFIRM=1 unless a human asked in that turn.
5. Rollback reads the snapshot written beside the document, not FILE=:
bin/rails current_scope:definitions:rollback SNAPSHOT=config/current_scope/roles.yml.pre.yml CONFIRM=1 ACTOR_ID=
```
Before finishing, read docs/SECURITY-CHECKLIST.md in the gem repo
(https://github.com/davidteren/current_scope/blob/main/docs/SECURITY-CHECKLIST.md)
and verify each item that applies.
```
## Declare subject identity and attach Owner
```text
Declare how a subject is identified so grants can be found across
environments. This is config.subject_identity, NOT config.subject_label.
Label is display-only and fail-soft. Identity is load-bearing: duplicate
keys raise, and resolve never inserts a subject.
1. Pick the identity:
- one column: config.subject_identity = :email
- composite: config.subject_identity = [:name, :email]
- split across tables: bin/rails generate current_scope:identity
then set config.subject_identity = CurrentScopeSubjectIdentity.new
and fill identify / resolve.
2. Check uniqueness against live rows:
bin/rails current_scope:identity:check
The task exits non-zero and names the colliding keys; it does not raise.
A collision raises ConfigurationError at BOOT, and identity:setup stops
before it writes. Do not first-row-win.
3. Dry-run a grant (writes nothing):
bin/rails current_scope:identity:setup SUBJECT=you@example.com
SUBJECT is whatever your identify returns. For a composite it is a YAML
sequence: SUBJECT='[Ada, you@example.com]'.
ROLE= defaults to Owner (full_access). Admin, if you create it, is not
full_access unless you set that yourself.
IDENTITY=email is an OVERRIDE for one run, for the case where
config.subject_identity is not written yet. Do not add it once step 1
is in the initializer: it replaces the configured identity, so under a
composite or an object identity it audits and grants by the wrong key,
and it removes the create_placeholder! factory step 5 needs.
4. Write only after the dry-run looks right:
bin/rails current_scope:identity:setup SUBJECT=you@example.com WRITE=1
That calls CurrentScope.grant! (ledger source: bootstrap). WRITE=1 also
creates the Role row if it does not exist, and seeds the default Owner
and Member roles when ROLE is unset. The dry-run plan names both.
5. Missing subject: never invent one in production. Outside production,
PLACEHOLDER=1 needs a create_placeholder! factory on the identity
object from `bin/rails generate current_scope:identity`. With one, a
dry-run prints the would-create line; without one the task stops and
says PLACEHOLDER=1 has no factory. Create the marked row with
PLACEHOLDER=1 WRITE=1. Production + PLACEHOLDER=1 is refused.
Hard stop: never invent a production subject.
```
## Enable separation of duties on an approve flow
```text
Enable CurrentScope separation of duties so an initiator can never approve
their own record. SoD is OFF by default — config.sod_actions = [] means the
veto never runs.
1. In config/initializers/current_scope.rb set
`config.sod_actions = %w[approve]` (action NAMES, not full keys).
2. On the model, define `def current_scope_initiator = `.
If an SoD action reaches a record whose class lacks this hook, the
resolver raises ConfigurationError — that is intended (fail loud).
3. CRITICAL: the controller's `current_scope_record` hook MUST return the
record on the SoD member action. If it returns nil there, the veto is
silently SKIPPED and the initiator can approve. This is the load-bearing
control — verify it, don't assume it.
4. Write the verification test and run it:
- grant the initiator a role that ticks approve
- POST the approve action as the initiator
- assert response :forbidden AND
response.headers["X-Current-Scope-Reason"] == "sod_veto"
If the reason is "no_grant" instead, SoD is not examining this action.
5. Do NOT add bypass logic unless explicitly asked. If a conditional
self-approval is a real requirement, use the engine's break-glass
(allow_sod_bypass) — never a hand-rolled branch, because break-glass
records the sod.bypassed audit event a hand-rolled branch forgets.
That recording requires config.audit enabled (the default); if an
unaudited bypass must be impossible, set config.audit = :strict.
```
## Debug a CurrentScope 403
```text
A request is being denied. Diagnose it from the engine's own signals; do not
guess or bypass the gate.
1. Read the X-Current-Scope-Reason response header (or the INFO log line
"[CurrentScope] denied () → 403"). Map the reason:
- no_grant → nothing grants this controller#action key; check
the role grid / seed the grant
- sod_veto → the subject initiated this record; that denial is
the anti-fraud control working — do not "fix" it
- model_undeclared → the controller needs `def current_scope_model = `
- model_invalid → current_scope_model returned something that is not
a concrete ActiveRecord class
- impersonation_gate→ non-GET/HEAD while impersonating; sessions are
read-only by design
- not_full_access → the management UI; only full-access subjects enter
2. In development/test, check the log for the engine's nudges (nil SoD
record, inert scoped grant, cross-controller key derivation) — each
names its one-line fix.
3. If there is no header and no log line, a host rescue_from may have
replaced the denial handler, or the controller never included Guard —
run `bin/rails current_scope:ungated` to check the gated surface.
```
## Migration tooling (shipped)
Migrating from Pundit, CanCanCan, or Action Policy? The
[`current-scope-migrate`](https://github.com/davidteren/current_scope/tree/main/.claude/skills/current-scope-migrate)
Claude Code skill is complete ([#45](https://github.com/davidteren/current_scope/issues/45)):
deterministic rule inventories for all three systems, decision report,
parity harness (per-system old-answer replay), reviewable role-backfill
migrations (enum column or rolify), and safe mechanical call-site rewrites
behind an explicit `--write`. The manual path remains the
[adoption guide](adopting-in-an-existing-app.md).
## Planned agent surfaces
These are tracked but **not shipped** — do not prompt an agent to use them
yet:
- Exposing the subject's abilities to a separate JS front-end (React/Next):
[#96](https://github.com/davidteren/current_scope/issues/96), Inertia
props: [#97](https://github.com/davidteren/current_scope/issues/97).
---
# Limitations
Source: https://davidteren.github.io/current_scope/limitations.md
# Limitations
What CurrentScope **is** and **is not**, in one place. Solid means honest
limits a host can read in one sitting.
## SSR-first today
CurrentScope is built for **server-rendered Rails** (controllers, ERB/Haml,
ViewComponents, Turbo). The ambient context and `allowed_to?` / `scope_for`
work anywhere the request has already established the subject.
**Not first-class yet:**
| Surface | Status |
|---|---|
| Separate JS front-end (React/Next over an API) | Open — [#96](https://github.com/davidteren/current_scope/issues/96) |
| Inertia.js shared props + denials | Open — [#97](https://github.com/davidteren/current_scope/issues/97) |
You can still authorize API requests that hit Rails controllers with Guard.
There is no shipped abilities payload, client SDK, or Inertia shared-props
contract. Do not plan a SPA cutover expecting that to exist today.
## Intentional residuals (do not "fix open")
These are deliberate product choices, not forgotten bugs. Documented so
operators and auditors know what to expect.
### A17 — Report mode does not absorb a missing `current_scope_initiator` (#133)
An action listed in `config.sod_actions` that reaches a model defining no
`current_scope_initiator` raises `CurrentScope::ConfigurationError`, and the
request returns **500 — under `config.enforcement = :report` exactly as under
`:enforce`**. Report mode downgrades a missing grant; it does not downgrade a
misconfiguration. Letting the request through would run a separation-of-duties
action with the veto never consulted, and answering 403 instead would make a
wiring mistake look like an ordinary denial.
**Host should:** audit `config.sod_actions` against your models before turning
report mode on. Since #133 the engine helps: it logs a **preflight warning when
the route set loads** — at boot in an eager-loading environment (production,
staging), and on the first request in development, whose route set is lazy —
naming every SoD action whose declared model cannot answer the hook. And
`bin/rails current_scope:report` lists both that static set and the requests
that actually raised (`access.sod_initiator_missing` rows).
Those ledger rows depend on the ledger: `config.audit` must be on (the default)
and the `current_scope_events` table must exist. With audit off or the migration
not yet run, the raises still happen and are still logged, but **no rows are
recorded** — so an empty section means "nothing recorded", never "nothing
happened". `bin/rails current_scope:report` prints your current `enforcement`
and `audit` settings whenever the ledger is empty, for exactly this reason.
**Operators should know:** the boot list is **partial by construction**, for the
same reason A16 is advisory. It can only inspect controllers that declare
`current_scope_model`, so an action without that declaration is *absent* from the
list rather than cleared; and the declared type names what the collection lists,
so a member action loading a different type is named against the wrong model. The
ledger rows are the proof the boot list cannot be.
### A16 — "check hooks" is advisory, not a verdict (#134)
The engine cannot prove which records a controller resolves to:
`current_scope_record` and `current_scope_model` run at request time. The
**check hooks** signal therefore compares the grant's type against the route
keys its role ticks, and inherits that comparison's blind spot for a controller
serving a type under a different name.
**Host should:** read the record hook before removing a grant on this signal
alone. The separate **cannot match** signal *is* proven and holds whatever your
hooks do.
### A15 — A declared chain is bounded at five hops, and truncation is silent to the user (#108)
`CurrentScope::ParentChain::MAX_PARENT_DEPTH` is 5. A grant held more than five
hops above a record does not match it, and a loop in the data
(`parent_id` pointing back up the tree) stops the walk where it repeats.
Both cases **deny** — fewer ancestors can only mean fewer grants match, never
more — and both log a warning naming the class and the reason. Neither raises.
That is deliberate: the chain shape is often *data*, so raising would let two
`UPDATE`s on a foreign key turn a live request into a 500 and break report
mode's promise never to break a request.
**Host should:** keep hierarchies within five levels, and add an acyclicity
check on write if users can re-parent records. Watch the log for
"current_scope_parent stopped walking" if a subject is missing access they
should have.
**Also true:** a parent-scoped grant opens member actions and collection
**reads**, but never a record-less non-read action such as `#create`. A role
that ticks `reports#create` and is held on a Project opens the index and 403s
`new`/`create`. Grant record-less keys org-wide, or on the collection's own
type.
### A14 — A scoped `full_access` grant does not cascade to children (#108)
When a model declares `current_scope_parent`, only roles that **explicitly tick
the key** reach its children. A scoped `full_access` grant reaches the record it
was granted on and stops there.
This is deliberate. Cascading it would mean one scoped `full_access` grant on a
root record opened every permission on every descendant, which is a far larger
grant than ticking one box implies. The visible oddity is that privilege stops
being monotonic in this one place: a `full_access` role reaches **fewer** records
through a chain than a role that merely ticks the key.
**Host should:** tick the keys on the role when blanket authority over a subtree
is what you want. The role editor's full-access label states the carve-out.
**Operators should know:** since **#134** the console and
`bin/rails current_scope:report` flag a scoped grant whose role can never match
anything, and separately flag one whose ticked keys do not name a controller for
the grant's type. The second is an **advisory, not a verdict** — see A16.
### A5 — Org grant + nil SoD record skips the veto
On an SoD-listed **member** action, if `current_scope_record` returns `nil`
(or a non-record), the veto has no initiator to measure and is **skipped**.
An org-wide grant can then allow the initiator through.
**Host should:** always return the AR record on SoD member actions. Dev/test
nudges (`warn_on_nil_sod_record`) and report-mode `access.sod_blind_spot`
surface the mistake. See the
[SoD guide](separation-of-duties.md).
Returning the record is only half of it — the model it belongs to must define
`current_scope_initiator`, or the gate raises instead of skipping. See A17.
### A2 — `actor_method` is only loud at boundary APIs
Without `config.actor_method`, the engine cannot see impersonation. SoD
`sod_identity = :either` and the mutation guard need it. We do not auto-detect
Pretender (or similar) wiring — false auto-detect would be worse than silence.
**Host should:** set `actor_method` when you impersonate; include the
[security checklist](security-checklist.md) item. Doctor/DX skill (#46) may
nudge later.
### A6 — `audit = true` degrades without the events table
If the ledger table is missing, `audit: true` **warns once and continues** so
upgrades do not hard-crash. Commits that would have been audited are not.
**Host should:** use `config.audit = :strict` when the ledger is mandatory
(production fraud-control). Run migrations before enabling.
### Trusted `current_scope_model`
The declared collection type is trusted like `current_scope_record`. A wrong
type + scoped full_access can open that controller's listed reads for the
wrong resource family (the #65 trade).
**Host should:** review the model hook the way you review the record hook.
### Report mode hard-403s `:model_undeclared` / `:model_invalid`
Report mode relaxes **only** ordinary `:no_grant`. The labels
`:model_undeclared` / `:model_invalid` appear when a **scoped grant would
otherwise satisfy** the record-less permission but the model hook is missing
or unusable — those stay hard 403 (with a reason header and a dev nudge).
With no qualifying scoped grant, the denial is plain `:no_grant` and report
mode still observes it. Deliberate: a retrofit must not wave through "you
forgot the type hook" when a grant is present.
**Host should:** declare `current_scope_model` (and a valid AR class) on
collection controllers that need scoped grants.
### GatingTripwire is opt-in
Controllers that never include `Guard` are not auto-gated. Auto-including on
every Metal controller would surprise hosts.
**Host should:** include Guard on app bases you care about, and optionally
`GatingTripwire` / `bin/rails current_scope:ungated` for inventory. Recommended
on the [security checklist](security-checklist.md).
### Parent/child cascade is opt-in, one declaration at a time
A scoped grant on a Project does **not** open Tasks under it *unless* Task
declares `current_scope_parent` (#108). Flat is the default and stays the
default; see A14 and A15 above for the two limits that come with opting in.
## Related
- [Security checklist](security-checklist.md) — deploy footguns
- [Upgrading](upgrading.md) — silent posture flips between versions
- [Solid-solution worklist Track 8](https://github.com/davidteren/current_scope/blob/main/docs/reviews/grok-whole-app-2026-07-19/08-solid-solution-worklist.md)
— source residuals table
---
# README highlights
Source: https://github.com/davidteren/current_scope/blob/main/README.md
# CurrentScope
[](https://rubygems.org/gems/current_scope)
[](https://github.com/davidteren/current_scope/actions/workflows/ci.yml)
[](https://github.com/davidteren/current_scope/actions/workflows/mutation.yml)
[](MIT-LICENSE)
[](https://davidteren.github.io/current_scope/)
[](https://github.com/davidteren/current_scope/issues/116)
> ## 🧪 Beta — usable, and we want your feedback
>
> The core is built and security-hardened: permissions derived from your routes,
> roles as editable data, per-record scoped grants, a separation-of-duties veto,
> impersonation, an append-only audit ledger, and a report-only rollout mode. The
> one published security defect in grant-id storage
> ([#151](https://github.com/davidteren/current_scope/issues/151)) is fixed and
> released in 0.5.0; the affected older versions are yanked.
>
> It is **not yet certified for production**, for one honest reason: the last gate
> to general availability is a real-world bake — one real app running
> `config.enforcement = :report`, reading `bin/rails current_scope:report`, then
> flipping to `:enforce`. Until a real host has run it enforced, "production-ready"
> is a claim with nothing behind it. That gate is tracked in
> [#116](https://github.com/davidteren/current_scope/issues/116); **1.0 is when it
> closes.**
>
> So please adopt it, run it in `:report` mode, and tell us what breaks — that
> feedback is exactly what gets it to 1.0. It is an **authorization** library, so
> mind the bar: a bug here is a user seeing or doing something they shouldn't.
> Anything you find goes on the
> [issue tracker](https://github.com/davidteren/current_scope/issues).
**Website:** [davidteren.github.io/current_scope](https://davidteren.github.io/current_scope/) —
overview, quickstart, the deep `docs/guides/` tree, the
[separation-of-duties guide](https://davidteren.github.io/current_scope/separation-of-duties.html),
the security checklist, and
[copy-paste prompts for AI agents](https://davidteren.github.io/current_scope/ai-agents.html).
Source lives in [`docs/site/`](https://github.com/davidteren/current_scope/blob/main/docs/site) plus [`docs/guides/`](https://github.com/davidteren/current_scope/blob/main/docs/guides);
the published site is generated from that one tree (`llms.txt`,
`llms-full.txt`, Markdown twins).
**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. A model can opt in to reaching down a
declared chain with `current_scope_parent :project`, so a role held on a
project covers that project's reports — including reports created after the
grant, and up to five hops of nesting.
Flat is still the default, a scoped `full_access` grant deliberately does not
cascade, and the four-eyes veto keeps reading the record you handed it. See
[Checking permissions](https://davidteren.github.io/current_scope/checking-permissions.md#a-grant-on-a-parent-record-108).
- **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, or an ALLOW
ancestor role that ticks the key
(opt-in; not scoped full_access)
5. record-less → no record: a scoped grant of the ALLOW
named type opens a listed
collection read (index by default)
6. otherwise → default deny
```
Step 4 also matches a grant on a declared parent when the child opted in
with `current_scope_parent`. Step 5 is why a scoped-only subject can reach
an index. A collection action names the type with `current_scope_model`.
The class form `allowed_to?(:index, Report)` names the type itself.
Without a type the gate stays closed. The listed read still opens only
when the subject's scoped list is not empty, including rows reached
through a declared parent. Other record-less keys (for example `create`)
need an explicit tick on the named type; a scoped `full_access` grant
does not open those. An action in `config.sod_actions` never opens on
this arm: the veto needs a record. See
[Checking permissions](https://davidteren.github.io/current_scope/checking-permissions.md#scoping-a-list-scope_for).
## Installation
> **Upgrading from 0.4 or earlier? Run the migrations.** 0.5 widens the columns
> that store a grant's subject and resource id, so UUID and other string primary
> keys are stored whole instead of being truncated to an integer
> ([#151](https://github.com/davidteren/current_scope/issues/151) — two subjects
> could collapse into one identity, and one inherit the other's roles). Run
> `bin/rails current_scope:install:migrations && bin/rails db:migrate && bin/rails db:test:prepare`; the engine
> refuses to boot until you do. If MySQL was loaded from `schema.rb`, also run
> `bin/rails current_scope:repair_schema`, then the same task under
> `RAILS_ENV=test`, because `db:test:prepare` loads that same `schema.rb`. It
> applies the binary collation and changes nothing when the columns are already
> right. Integer, UUID and ULID keys all work, up to 64 characters. See
> [UPGRADING.md](https://github.com/davidteren/current_scope/blob/main/UPGRADING.md).
This is the **canonical greenfield quickstart** (new app, or install before
users hit gated controllers). The same numbered path lives on the
[docs site](https://davidteren.github.io/current_scope/quickstart.html) and in
the install generator's next-steps text (#25). **Existing apps with traffic
must use [report mode first](#retrofitting-an-app-that-already-has-users)**
before bootstrap — do not cut over blind.
```ruby
# Gemfile
gem "current_scope"
```
```bash
bin/rails generate current_scope:install
bin/rails current_scope:install:migrations && bin/rails db:migrate
```
**1. Include the concerns** in `ApplicationController` — `Context` populates
the ambient subject from your authentication, `Guard` gates every action:
```ruby
class ApplicationController < ActionController::Base
include CurrentScope::Context # sets CurrentScope::Current.user from current_user
include CurrentScope::Guard # fail-closed gate on every action
end
```
**2. Skip the gate on sign-in** (and other public endpoints). **Do not skip
this step** — the gate is fail-closed and covers *everything*, including login.
Prefer the declared form so the role grid shows **why** the gate is off:
```ruby
class SessionsController < ApplicationController
current_scope_skip_gate!(reason: "sign-in must run without a grant")
# While impersonating, sign-in/out must also clear the mutation guard or a
# POST that ends act-as is blocked (same as bare skip of the permission gate):
skip_before_action :current_scope_mutation_guard!
# bare skip_before_action :current_scope_check! still works, but the grid
# marks it as an unexplained "gate not run"
end
```
A skipped controller is unprotected by the permission gate — supply your own
auth where that matters ([security checklist](https://davidteren.github.io/current_scope/security-checklist.md)).
**3. Bootstrap the first admin.** By default the management UI only admits
full-access subjects. A host can also admit delegates through
`management_authorizer` (see [Editable role administration](#editable-role-administration)).
The seeded **Member** role starts with **zero** permissions until you edit it:
```bash
bin/rails current_scope:grant SUBJECT_ID=YOUR_USER_ID
# or: CurrentScope.grant!(User.first) # upserts Owner — not RoleAssignment.create!
```
`grant!` reuses an existing role named Owner without forcing `full_access`.
On a greenfield seed that is fine (seed_defaults! creates Owner as full_access).
If someone renamed/stripped Owner earlier, repair with
`CurrentScope::Role.find_by!(name: "Owner").update!(full_access: true)` before
expecting `/current_scope` to open.
**4. Manage roles** at `/current_scope`. A Guard denial is HTTP 403 with
`X-Current-Scope-Reason` (`no_grant`, `sod_veto`, …) when the default
engine rescue runs. Host `rescue_from` handlers can replace that response.
To copy a role grid between environments, export YAML and apply it with a
confirm flag. See [Portable role definitions](https://davidteren.github.io/current_scope/role-definitions.md).
Assignments are not in that document.
### Retrofitting an app that already has users
> **Retrofitting a real app?** There's a full guide:
> [Adopting CurrentScope in an existing app](https://davidteren.github.io/current_scope/adopting-in-an-existing-app.md)
> — 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.
>
> **Shipping?** Read the [Security & production checklist](https://davidteren.github.io/current_scope/security-checklist.md)
> first — excluded controllers, the 403/404 record oracle, and the pre-ship tick list.
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:
```ruby
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:
```bash
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 *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, re-exercise, and flip to `:enforce` once newly
exercised requests stop adding rows (the report reads the append-only
ledger, so historical rows do not clear). 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:
```ruby
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 — but it **logs the blind spot and records `access.sod_blind_spot`** (not `access.would_deny`; granting will not clear the 403). `rails current_scope:report` lists them separately. |
| SoD actions on a model with no `current_scope_initiator` | The veto cannot be measured at all, so the resolver raises `ConfigurationError` and the request **500s — under `:report` exactly as under `:enforce`**. Passing it through would run a four-eyes action unchecked; a 403 would make a wiring mistake read as an ordinary denial. The engine warns about these **when the routes load** (boot in production and staging; development's lazy route set defers it to the first request) where a controller declares `current_scope_model`, records `access.sod_initiator_missing` when traffic finds one, and `rails current_scope:report` lists both. |
| 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):
```ruby
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:
```bash
bin/rails current_scope:grant SUBJECT_ID=1 # grants the full-access Owner role
```
To attach by a portable key (email, or a composite) instead of the raw id,
set `config.subject_identity` and run `current_scope:identity:setup`. That
knob is not `config.subject_label`. `CurrentScope.identify_subject(record)`
returns that portable key and `CurrentScope.resolve_subject(key)` returns the
record for it in this environment. See the
[adoption guide](https://davidteren.github.io/current_scope/adopting-in-an-existing-app.md#declare-how-a-subject-is-identified).
Or in `db/seeds.rb`:
```ruby
CurrentScope.seed_defaults! # Owner (full_access) + Member
CurrentScope.grant!(User.first) # Owner role; full_access only if that role has it
```
Then manage everything at `/current_scope` (full-access subjects by default;
see [Editable role administration](#editable-role-administration) for
delegated access): the role grid, org-wide assignments, scoped grants.
## Documentation
| Guide | What it covers |
|---|---|
| [Concepts & glossary](https://davidteren.github.io/current_scope/concepts-and-glossary.md) | Decision order + core vocabulary — **read first** |
| [Checking permissions](https://davidteren.github.io/current_scope/checking-permissions.md) | `allowed_to?`, `scope_for`, record-level, scopeable models |
| [Separation of duties & break-glass](https://davidteren.github.io/current_scope/separation-of-duties-and-break-glass.md) | SoD veto, `allow_sod_bypass` |
| [Impersonation](https://davidteren.github.io/current_scope/impersonation.md) | Act-as, mutation guard, denial shape |
| [Configuration reference](https://davidteren.github.io/current_scope/configuration-reference.md) | Initializer knobs, enforcement, audit, diagnostics |
| [Testing](https://davidteren.github.io/current_scope/testing.md) | `TestHelpers`, grants in request specs |
| [Adopting in an existing app](https://davidteren.github.io/current_scope/adopting-in-an-existing-app.md) | Report-mode retrofit ladder |
| [Security & production checklist](https://davidteren.github.io/current_scope/security-checklist.md) | Pre-ship tick list |
| [Is it the right fit?](https://davidteren.github.io/current_scope/comparison.html) | CurrentScope vs Pundit, Action Policy, CanCanCan, Banken, Oso — and adoption cost |
| [Docs site](https://davidteren.github.io/current_scope/) | Published tree (same guides as this table), `llms.txt` / `llms-full.txt`, Markdown twins |
Root [CONCEPTS.md](https://github.com/davidteren/current_scope/blob/main/CONCEPTS.md) is the longer glossary narrative for maintainers.
---
# UPGRADING.md
Source: https://github.com/davidteren/current_scope/blob/main/UPGRADING.md
# Upgrading CurrentScope
Read the [CHANGELOG](https://github.com/davidteren/current_scope/blob/main/CHANGELOG.md) for every version you cross. This file calls
out the silent posture changes that a changelog line is easy to miss.
## Unreleased: subject identity is opt-in (#158)
`config.subject_identity` defaults to the primary key. Existing installs
change nothing until they set it. A String or Proc is rejected (that shape
is `subject_label`). Duplicate natural keys raise at boot. `resolve` never
inserts a subject. `PLACEHOLDER=1` is refused in production.
## Unreleased: custom polymorphic tokens are first-class (#155)
Stored grant types use `polymorphic_name`, not `base_class.name`. Default
models do not change. If a model overrides `polymorphic_name` (or shortens it
with `store_full_class_name = false`):
- Collection lists and record-less write checks now see those grants.
- Two classes must not share a token. Rebuild raises `ConfigurationError`.
That includes a shortened name that matches another loaded class (for
example `Admin::User` storing `"User"` next to `::User`). STI siblings
that store their base name are not a clash.
- Optional: `config.polymorphic_class_names = { "token_docs" => "TokenDocument" }`
for a token that must resolve before the model is loaded. After a namespaced
model with `store_full_class_name = false` is loaded, a shortened token that
does not constantize is registered automatically. The class must actually
store that token: a mapping to a class that does not emit it is refused with a
`ConfigurationError` (under `eager_load` that fails boot loudly, rather than
resolving the token to the wrong class). Leaving a token unmapped is what keeps
a stale grant inert; a wrong mapping raises, it does not stay inert.
Auto-registration only sees models loaded when the registry rebuilds. Under
`eager_load` (production) that is every model; in development a custom-token
model autoloaded after boot is not reverse-resolvable until the next reload.
Map any token that must resolve regardless of load timing.
- A token is the stored grant identity. If you retire a model and later reuse
its `polymorphic_name` token for a different class, existing grants rebind to
the new class — treat a token rename or reuse as a data migration.
An unmapped custom token stays inert. That is not a permit.
## 0.1 → 0.2: SoD became opt-in (silent fail-open if you relied on defaults)
**If you used separation of duties on 0.1 defaults, re-add:**
```ruby
# config/initializers/current_scope.rb
config.sod_actions = %w[approve] # or whatever actions you SoD-gate
```
### What changed
`config.sod_actions` default flipped from `%w[approve]` to `[]`. Empty means
the SoD veto never runs. A 0.1 host that never set `sod_actions` and relied on
the old default **loses four-eyes on upgrade with no error and no warning** —
self-approvals that used to 403 start succeeding.
That is exactly the silent fail-open class this gem otherwise crusades against.
It is documented here and on the
[docs site Upgrading page](https://davidteren.github.io/current_scope/upgrading.html)
because the earliest adopters are the ones hit.
### How to check
1. Did you **intend** to keep separation of duties after upgrade?
2. If yes: search for `current_scope_initiator` on models. If any model defines
it and `sod_actions` is empty, re-set `sod_actions` and re-run the
initiator-cannot-approve test from the
[SoD guide](https://davidteren.github.io/current_scope/separation-of-duties.html).
3. If no (you deliberately left SoD off): leave `sod_actions` empty. Initiator
hooks alone are not proof of a mistake.
Also in 0.2: declared Rails floor `>= 8.1` (management UI uses `params.expect`).
## 0.2 → 0.3: management route rename (loud — 404)
Org-wide role assignment routes are plural: `resources :role_assignments`.
Direct POSTs to the singular path **404**. Stale helper calls
(`role_assignment_path`) can fail earlier with an **undefined helper** /
URL-generation error. Engine UI is fine; only programmatic callers need a
path update. See CHANGELOG errata.
## 0.3 → 0.4
Solid-solution Phase 1, denial ergonomics, security checklist, docs site, and
the migration toolkit. No intended host API break. Boot now **raises** if
`sod_bypass_permission` is listed in `sod_actions` (#40) instead of 500ing on
the first real bypass.
## 0.4 → 0.5: run the migrations, or the engine will not boot (security, #151)
**This release fixes a privilege escalation. If your subject or scoped-resource
models use UUID or other string primary keys, you were affected on 0.2, 0.3 and
0.4.**
Grant ids lived in integer columns, so a non-numeric key was cast on write:
`"7f00aaaa-…"` and `"7f00bbbb-…"` both stored as `7`, and a key starting with a
letter stored as `0`. Two subjects became one, and one inherited the other's
org-wide role, `full_access` included. Nothing failed; the association still
resolved, to the wrong record.
### What you must do
```bash
bin/rails current_scope:install:migrations
bin/rails db:migrate
bin/rails db:test:prepare
```
`db:migrate` migrates development only. The test database is a separate database
with the same guard on it, so without `bin/rails db:test:prepare` the next
`bin/rails test` aborts at boot with `CurrentScope::ConfigurationError` naming
`subject_id`. That message names the database it judged, and prefixes the repair
it prescribes with `RAILS_ENV=test`, so it points at the right database rather
than back at the one you have just migrated. `bin/rails db:test:prepare` is the
fix here, and `RAILS_ENV=test bin/rails current_scope:repair_schema`, which the
message prescribes, also works. Rails does not repair this for you, even though it
normally keeps the test schema current: `maintain_test_schema!` runs from
`rails/test_help`, and the engine's check runs earlier, while `config/environment`
loads. Database tasks are exempt from the boot refusal, so `db:test:prepare` runs
on a host that has not been migrated yet.
On MySQL, run `RAILS_ENV=test bin/rails current_scope:repair_schema` as well.
Whether `db:test:prepare` gives the test columns their binary collation depends on
where `schema.rb` was dumped. A dump taken from MySQL after `db:migrate` carries a
per-column `collation:`, because the migration collates the column while the table
keeps the server default and Rails dumps a column collation that differs from its
table's. A dump taken from PostgreSQL or SQLite carries none, so on a team that
develops on one adapter and runs MySQL in CI the test columns come out case and
accent insensitive and the engine refuses to boot. The repair task is idempotent
and changes nothing when the columns are already right, so running it settles the
question either way. (A host on `config.active_record.schema_format = :sql` builds
the test database from `structure.sql`, which always carries the collation.)
The columns become `varchar(64)`, so integer, UUID and ULID keys are all stored
whole. **The engine raises at boot until this migration has run** — a gem upgrade
alone would leave the escalation in place while every code path looked correct.
A model whose declared primary key is a business column (`self.primary_key = "code"`)
while a leftover `id` column still exists is also supported: grants store that
declared key and lookups use it. Composite and nil primary keys are still refused.
The migration rewrites both grant tables under an exclusive lock (PostgreSQL
`ACCESS EXCLUSIVE`, MySQL `ALGORITHM=COPY`). Grant tables are normally small, so
this is quick; if yours is large, schedule it like any other table rewrite, or
run it through your usual online-DDL tool.
Database, installer and `assets:` tasks are exempt from the boot refusal, so the
repair and your asset build still run on an unmigrated host. Everything that
serves traffic or runs your code — server, console, `runner`, and `db:seed` —
is refused. If some other build step must boot before the migration can run, set
`CURRENT_SCOPE_SKIP_SCHEMA_CHECK=1` **for that one command** (the literal string
`1`; any other value is ignored and logged). Setting it for a process that serves
traffic turns the guard off and puts the escalation back.
### If your database was built from `schema.rb`
New apps, CI, and fresh checkouts load `schema.rb` rather than running
migrations — and that marks every migration as already applied, so `db:migrate`
finds nothing pending. Whether `schema.rb` carries the binary collation depends
on the adapter it was dumped from: a dump taken from MySQL keeps a per-column
`collation:`, while a dump taken from PostgreSQL or SQLite carries none. So on a
team that develops on one adapter and runs MySQL elsewhere, that combination
leaves the columns case-insensitive, the engine refusing to boot, and
`db:migrate` unable to help. Run the repair task instead:
```bash
bin/rails current_scope:repair_schema
```
It is idempotent and safe to re-run, and it is exempt from the boot refusal so
it works on a database the engine will not otherwise start against.
**On MySQL, run the repair before seeds that create grants.** `db:setup`,
`db:reset`, and `db:prepare` load `schema.rb` and then run your seeds in the same
process, so if that `schema.rb` carries no collation (see above) the columns are
still case-insensitive when the seeds run. A seed that creates a grant is
refused (the write path re-checks the schema, on purpose — an unrepaired column
is the #151 collision). Run `bin/rails current_scope:repair_schema` after loading
the schema and before seeding grants, or keep grant-creating seeds out of the
one-shot rebuild. This is the guard working, not a bug: it fails closed rather
than seed collapsible grants.
On MySQL the columns are given a binary collation: `utf8mb4_0900_bin` where the
server offers it (8.0.17+), otherwise `utf8mb4_bin`. The server default is case-
and accent-insensitive, which would make `"ABC"` and `"abc"` — or `"jose"` and
`"josé"` — the same subject. `utf8mb4_0900_bin` is preferred because it is also
`NO PAD`, so `"abc"` and `"abc "` stay distinct too. A primary key is an
identifier, not prose.
Keys longer than 64 characters are rejected rather than truncated, because a
truncated key names the wrong record.
### Every host: grant ids now read back as strings
This part affects you **even if all your keys are integers**, because the column
type changed for everyone:
```ruby
grant = CurrentScope::RoleAssignment.find_by(subject: user)
grant.subject_id # => "7" (was 7)
```
**Nothing raises.** Code that compares a stored grant id against a model id keeps
running and quietly gets the wrong answer.
**Check your own code for these shapes:**
- **A tuple or Set comparison against live ids**, such as
`[subject_type, subject_id, resource_type, resource_id]` read from grants and
compared against the same tuple built from records. Every stored row now sorts
as stale and every wanted row as missing, so a sync that deletes the stale set
deletes every grant and creates nothing.
- **A hash keyed by `subject_id` or `resource_id`**, then looked up with a model
id: `assignments.to_h { |a| [ a.subject_id, a.role.name ] }` followed by
`hash[user.id]`. Every lookup misses and the value renders blank.
- **Any bare `==` between a stored id and a model id.**
`grant.subject_id == user.id` is now `false` where it used to be `true`.
The first two are real cases from a live 0.4 to 0.5 upgrade of a production app.
Neither raised, neither logged a line, and both exited 0.
The repair is the same for all three: compare `.to_s` on both sides, as in
`grant.subject_id.to_s == user.id.to_s`.
**Your Active Record queries are safe, so do not rewrite them.** `where(subject: user)`,
`where(subject_id: user.id)`, and `scope_for` all still match, because Active
Record casts the bound value to the column type on the way in. That covers Active
Record hash and record conditions on every adapter: the cast runs in Ruby, in
`ActiveModel::Type::String`, before the value reaches your database, so
`where(subject_id: 7)` sends `subject_id = '7'` on PostgreSQL, MySQL and SQLite
alike. A raw SQL fragment such as `where("subject_id = ?", user.id)`, or a join
comparing the column against an integer column, skips that cast. There the
comparison happens inside the database, each database treats a string against an
integer differently, and this document has only been checked against PostgreSQL.
Check those by hand.
To find these shapes in your own application:
```bash
grep -rnE "subject_id|resource_id" app lib db
```
Read the hits that compare, that key a hash, or that sit in a tuple or a Set.
Leave the `where(...)` calls alone.
A grant is also now refused at write time when the id is not a legal key for the
model it names — `resource_type: "Project"` with a UUID `resource_id`, say. Such
a row could never identify a real `Project`, and left alone the read path would
cast it back to a *different* project's id. If you build grants from parameters
or an import rather than from a located record, expect that validation to start
rejecting rows it previously accepted; those rows were never safe.
### Audit the rows you already have
**Widening the column does not repair rows already written.** Once `"7f00aaaa-…"`
was stored as `7`, the original value is gone. After migrating, those grants match
nobody, so they fail closed (access lost, not gained) and appear as inert grants.
They must be re-granted.
Every grant held on a type whose key is not an integer is suspect — not only the
ones now pointing at nothing. A collapsed value could also land ON a real record
(`"7f00…"` becomes `7`, and another record's key is `7`), which is the dangerous
case an orphan check misses:
```ruby
[[CurrentScope::RoleAssignment, %w[subject]],
[CurrentScope::ScopedRoleAssignment, %w[subject resource]]].each do |model, sides|
sides.each do |side|
model.distinct.pluck(:"#{side}_type").compact.each do |type|
# polymorphic_class_for, not safe_constantize: the column stores a Rails
# TOKEN, which a custom polymorphic_name or store_full_class_name = false
# can shorten.
klass = begin
ActiveRecord::Base.polymorphic_class_for(type)
rescue NameError
next
end
key = klass.primary_key
next if key.is_a?(String) && klass.type_for_attribute(key).type == :integer
count = model.where("#{side}_type" => type).count
puts "RE-GRANT: #{count} #{model.name} row(s) with #{side}_type=#{type} (#{klass.name} keys on #{key.inspect})"
end
end
end
```
## 0.4 → 0.5, separately: a mis-declared `current_scope_parent` now fails the deploy (#139)
This is an unrelated change that lands in the same release. It is not part of the
#151 fix above and needs its own attention.
**If your app boots today and stops booting after upgrading**, you have a
`current_scope_parent` declared on a `belongs_to` with a custom `primary_key:`.
That was already broken; it was just never checked. The error names the model
and the association.
```ruby
# The shape that now refuses to boot:
class Report < ApplicationRecord
belongs_to :project, primary_key: :slug, foreign_key: :project_slug
current_scope_parent :project # <- refused
end
```
### What changed
The check itself is not new — `ParentChain.validate_declarations!` has always
refused this. What changed is **when it runs**. It used to run only from
`to_prepare`, which railties executes *before* eager loading, so it saw only the
models something else had already loaded. In production that is close to none,
so the check could be skipped entirely for the model that needed it. It now also
runs after eager loading, where the registry is complete.
**Where it still does not look.** That second pass is gated on
`config.eager_load`, because with eager loading off it would autoload reloadable
models during initialization — which Rails warns against, and which pins
constants the first reload then makes stale. So:
| `config.eager_load` | Coverage |
|---|---|
| `true` (production, staging by default) | every declaring model that was **eager-loaded** (and thus registered) is validated at boot |
| `false` (development, test, and any environment that turns it off) | only models already loaded when `to_prepare` runs — which grows across reloads, but is never a guarantee |
Models excluded from eager load (`do_not_eager_load`, paths outside
`eager_load_paths`) still register only when first loaded, and nothing on the
request path re-runs this check. The same is true of a model first loaded from
a host `after_initialize` block that runs *after* this engine's pass (callback
order among `after_initialize` blocks is registration order, not "after every
host hook").
When the pass *does* run, `validate_key!` resolves `reflection.klass` on each
declaring model's parent association. A parent type kept off the eager-load
surface can still be autoloaded at that moment so the primary-key comparison
can run. That is intentional (skipping it would leave the dangerous chain
unchecked) and only bites hosts with a partial eager-load surface.
If you deploy with `eager_load = false`, or keep declaring models (or their
parent types) off the eager-load surface, this check does **not** fully protect
you without that residual load. That is the same partial-coverage bargain the
permission catalog makes, and it is stated rather than implied.
### Why it is worth a broken deploy
An unvalidated chain of this shape does not fail safely. Both the collection
query (`scope_for`) and the unloaded member walk (`load_parent`) key the parent
on its **primary key**, so they compare values from two different columns. The
result is wrong in both directions on both surfaces:
- records the grant *should* reach are **not** returned / denied, and
- unrelated records **are** returned / allowed whenever their foreign-key value
collides with a granted parent's id — with a numeric custom key, a dense
collision space.
That second half is a subject seeing (and opening) records nobody granted them.
Failing the deploy is the correct outcome; the alternative is continuing to
serve wrong authorization answers quietly.
### If you need to ship right now
Remove the `current_scope_parent` declaration to restore the previous
behaviour (flat matching, no chain), fix the association to key on the primary
key, and re-declare.
## Related silent-security docs (not version-specific)
- Collection actions in `sod_actions` are **no-ops** for the veto (no record →
no initiator). Bulk self-approval needs per-record `allowed_to?` in the action
body. See [Separation of duties](https://davidteren.github.io/current_scope/separation-of-duties.md).
- Advisory `allowed_to?` never consults the permission catalog (typos are silent
false; a stale grant row can be silent true). The Guard is authoritative.
See README "Checking permissions".
Tracked originally as #27, #29, #36.