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 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, 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.)

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

Turning it on

Three declarations. First, list the actions an initiator may never perform on their own record:

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

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):

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.

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:

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:

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

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 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 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 README’s SoD section is the canonical deep treatment — record-less refusals, the report-mode blind spot, sod_identity modes, and the break-glass host recipe.