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 ALLOW
5. otherwise → default deny
One nuance the diagram folds into step 4: a record-less check (a collection action like index) can be opened by a scoped grant too — listed read actions derive their answer from the scoped list (scope_for). The README’s 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_actionsdefaults 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.
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.
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_bypassdefaults tofalse; 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 thebypass_sodpermission — 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.bypassedledger event and setsX-Current-Scope-Reason: sod_bypassedon the response.
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.