# 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 [![Gem Version](https://img.shields.io/gem/v/current_scope)](https://rubygems.org/gems/current_scope) [![CI](https://github.com/davidteren/current_scope/actions/workflows/ci.yml/badge.svg)](https://github.com/davidteren/current_scope/actions/workflows/ci.yml) [![Mutation](https://github.com/davidteren/current_scope/actions/workflows/mutation.yml/badge.svg)](https://github.com/davidteren/current_scope/actions/workflows/mutation.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](MIT-LICENSE) [![Website](https://img.shields.io/badge/website-davidteren.github.io%2Fcurrent__scope-4d7cfe)](https://davidteren.github.io/current_scope/) [![Status: beta](https://img.shields.io/badge/status-beta-4d7cfe)](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.