Skip to content

Add optional 4-eyes / different-approver mode for purchase approvals (segregation of duties) #1005

Description

@cristim

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

  1. Schema: add require_different_approver BOOLEAN NOT NULL DEFAULT false to the purchasing_policies table (find the migration number after the current highest).
  2. 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).
  3. 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).
  4. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions