# 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.
