Summary
Add an optional 4-eyes approval mode (segregation of duties) on top of #995's creator-scope model. When enabled, the user who creates a scheduled purchase or purchase execution cannot approve it themselves — a different person with approval rights must do so. Compromise of one account no longer suffices to move money out.
This is a standard SOX / SOC2 / financial-controls pattern and complements #923's Purchaser group: with both enabled, executing a purchase requires (a) a Purchaser-group member AND (b) a different Purchaser-group member from whoever created the execution.
Scope
The mode gates the approval verb chain on purchase executions:
approve-own (default grant on regular users) — becomes implicitly denied when session.UserID == execution.CreatedByUserID AND 4-eyes mode is on.
approve-any (custom group / future operator role) — same denial.
- Admin wildcard — also subject to the denial when the mode is on. (Admins frequently double as creators; not exempting them defeats the control. Admins who need to approve their own creations must disable the mode in Settings first.)
Execute / cancel / retry / pause / resume / run / delete are NOT gated by this mode — those are bounded by #923's Purchaser group and #995's creator-scope. 4-eyes is specifically about the approval moment — the signature that authorizes money out.
Configuration
New global setting under Admin -> Purchasing Policies: require_different_approver boolean, default off (preserve current behavior on upgrade).
Per-account override is out of scope for v1 — users can request it later if needed. Threshold-based variants ("4-eyes only above $X potential savings") also out of scope; would be a follow-up.
Backend design
- Schema: add
require_different_approver BOOLEAN NOT NULL DEFAULT false to the purchasing_policies table (find the migration number after the current highest).
- Handler check: add
Handler.requireDifferentApprover(session, execution) error:
- Returns nil if mode is off (
config.RequireDifferentApprover == false).
- Returns nil if
session.UserID is non-empty AND execution.CreatedByUserID != nil AND *execution.CreatedByUserID != session.UserID.
- Returns
NewClientError(403, "approval declined: 4-eyes mode requires a different approver than the requester") otherwise.
- Returns 500 on nil-auth (fail closed, matches the carve-out idiom).
- Call sites: invoke
requireDifferentApprover at the top of every approve handler — both the session-authed approve endpoints and the legacy email-token approve path (the email token authorizes a session, so the same check applies; the legacy path was never bounded by user identity, but it should be now when the mode is on).
- NULL-creator rows: legacy rows with
CreatedByUserID = NULL cannot satisfy the "different from creator" check — fail 403 with a specific message noting the row predates the dual-control feature. Admins resolve by either disabling the mode or recreating the row.
Frontend design
- Settings -> Purchasing Policies: new toggle "Require different approver (4-eyes mode)" with help text explaining SOX/SOC2 framing.
- Purchase History / Purchases pages: when the mode is on, on a row whose
created_by_user_id == session.user.id:
- Hide the Approve button.
- Show a small disabled-state badge: "Awaiting different approver" (so the requester knows the row is parked, not lost).
- Banner on the Purchase History page when the mode is on: "4-eyes approval enforced — purchases must be approved by a different user than the one who created them."
Tests (fail pre-fix, pass post-fix)
Backend
TestRequireDifferentApprover_ModeOff_AllowsSameUser: with mode off, creator can self-approve. (Regression coverage for the default.)
TestRequireDifferentApprover_ModeOn_DeniesSameUser: with mode on, creator gets 403 when attempting to approve their own row even with approve-any.
TestRequireDifferentApprover_ModeOn_AllowsDifferentUser: different user with approve-own or approve-any succeeds.
TestRequireDifferentApprover_ModeOn_DeniesAdminSelfApprove: admin who created the row cannot self-approve under mode on (asserts admin wildcard does not bypass).
TestRequireDifferentApprover_ModeOn_NullCreatorDenied: legacy NULL-creator row returns 403 with the documented message.
TestRequireDifferentApprover_NilAuth_500: nil-auth returns 500 (fail closed).
TestRequireDifferentApprover_EmailTokenPath_ModeOn: the legacy email-token approve route also enforces the rule.
Frontend
TestApproveButton_Hidden_When4EyesAndOwnRow: creator viewing own pending row with mode on sees no Approve button + the "Awaiting different approver" badge.
TestApproveButton_Shown_When4EyesAndOtherRow: different user viewing the same row sees the Approve button.
TestSettingsToggle_PersistsRequireDifferentApprover: toggling the Settings checkbox PUTs require_different_approver: true to the policies endpoint.
Migration / rollout notes
- Default off on upgrade. Existing deployments keep current behavior. Admins opt in via Settings.
- First-run hint for new deployments: when an admin first opens Admin -> Purchasing Policies, surface a one-time tooltip recommending the mode for orgs with separate finance team.
- Audit trail: every denied attempt under this mode logs
purchase_id, attempted_approver_id, creator_id, policy_mode=four-eyes. Logged at WARN.
Out of scope (separate issues if requested)
- Threshold-based 4-eyes (only above $X).
- Per-account or per-plan override.
- Multi-approver chains (require N approvers).
- Approval queue UI improvements (e.g. dashboard widget surfacing "executions awaiting a non-creator approver").
Related
Summary
Add an optional 4-eyes approval mode (segregation of duties) on top of #995's creator-scope model. When enabled, the user who creates a scheduled purchase or purchase execution cannot approve it themselves — a different person with approval rights must do so. Compromise of one account no longer suffices to move money out.
This is a standard SOX / SOC2 / financial-controls pattern and complements #923's Purchaser group: with both enabled, executing a purchase requires (a) a Purchaser-group member AND (b) a different Purchaser-group member from whoever created the execution.
Scope
The mode gates the approval verb chain on purchase executions:
approve-own(default grant on regular users) — becomes implicitly denied whensession.UserID == execution.CreatedByUserIDAND 4-eyes mode is on.approve-any(custom group / future operator role) — same denial.Execute / cancel / retry / pause / resume / run / delete are NOT gated by this mode — those are bounded by #923's Purchaser group and #995's creator-scope. 4-eyes is specifically about the approval moment — the signature that authorizes money out.
Configuration
New global setting under Admin -> Purchasing Policies:
require_different_approverboolean, default off (preserve current behavior on upgrade).Per-account override is out of scope for v1 — users can request it later if needed. Threshold-based variants ("4-eyes only above $X potential savings") also out of scope; would be a follow-up.
Backend design
require_different_approver BOOLEAN NOT NULL DEFAULT falseto thepurchasing_policiestable (find the migration number after the current highest).Handler.requireDifferentApprover(session, execution) error:config.RequireDifferentApprover == false).session.UserIDis non-empty ANDexecution.CreatedByUserID != nilAND*execution.CreatedByUserID != session.UserID.NewClientError(403, "approval declined: 4-eyes mode requires a different approver than the requester")otherwise.requireDifferentApproverat the top of every approve handler — both the session-authed approve endpoints and the legacy email-token approve path (the email token authorizes a session, so the same check applies; the legacy path was never bounded by user identity, but it should be now when the mode is on).CreatedByUserID = NULLcannot satisfy the "different from creator" check — fail 403 with a specific message noting the row predates the dual-control feature. Admins resolve by either disabling the mode or recreating the row.Frontend design
created_by_user_id == session.user.id:Tests (fail pre-fix, pass post-fix)
Backend
TestRequireDifferentApprover_ModeOff_AllowsSameUser: with mode off, creator can self-approve. (Regression coverage for the default.)TestRequireDifferentApprover_ModeOn_DeniesSameUser: with mode on, creator gets 403 when attempting to approve their own row even withapprove-any.TestRequireDifferentApprover_ModeOn_AllowsDifferentUser: different user withapprove-ownorapprove-anysucceeds.TestRequireDifferentApprover_ModeOn_DeniesAdminSelfApprove: admin who created the row cannot self-approve under mode on (asserts admin wildcard does not bypass).TestRequireDifferentApprover_ModeOn_NullCreatorDenied: legacy NULL-creator row returns 403 with the documented message.TestRequireDifferentApprover_NilAuth_500: nil-auth returns 500 (fail closed).TestRequireDifferentApprover_EmailTokenPath_ModeOn: the legacy email-token approve route also enforces the rule.Frontend
TestApproveButton_Hidden_When4EyesAndOwnRow: creator viewing own pending row with mode on sees no Approve button + the "Awaiting different approver" badge.TestApproveButton_Shown_When4EyesAndOtherRow: different user viewing the same row sees the Approve button.TestSettingsToggle_PersistsRequireDifferentApprover: toggling the Settings checkbox PUTsrequire_different_approver: trueto the policies endpoint.Migration / rollout notes
purchase_id,attempted_approver_id,creator_id,policy_mode=four-eyes. Logged atWARN.Out of scope (separate issues if requested)
Related
CreatedByUserIDfield this enhancement keys off.