feat(api,frontend): 4-eyes approval mode — creator cannot self-approve (closes #1005) - #1500
Conversation
#1005) Migration 000065 adds a BOOLEAN NOT NULL DEFAULT false column to global_config that enables 4-eyes approval mode. GlobalConfig struct + PostgresStore GetGlobalConfig/SaveGlobalConfig wired up; pgxmock tests updated to include the new column in SELECT fixtures.
…handlers (issue #1005) - requireDifferentApprover enforces 4-eyes policy: when RequireDifferentApprover is set, the creator of an execution cannot approve it themselves (admin wildcard NOT exempt). NULL-creator legacy rows fail 403 with an admin-directed message. Nil session with mode on fails 500 (fail-closed). - Wired into approvePurchaseViaSession (session path) and the email-token path in approvePurchase (both paths enforced per the issue design). - 7 new backend tests covering: mode-off allow, mode-on deny same user, mode-on allow different user, admin self-deny, NULL creator deny, nil-auth 500, and email-token path enforcement. - Updated 3 existing tests to mock GetGlobalConfig (new call in the session path).
…fferent_approver Rebasing the 4-eyes commits (issue #1005) onto current main required updating tests that hardcoded the SaveGlobalConfig arg count (23 -> 24) and the GetGlobalConfig column list, plus two fail-closed assertions that now catch the config-read error inside requireDifferentApprover before the purchase-delay check reaches it.
|
@coderabbitai review |
✅ Action performedReview finished.
|
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (33)
📝 WalkthroughWalkthroughAdds an optional ChangesFour-eyes approval mode
Estimated code review effort: 4 (Complex) | ~60 minutes Possibly related PRs
Suggested labels: ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
golangci-lint's misspell check flagged "behaviour" in the doc comment on internal/api/handler_purchases.go:931.
…e + banner Adds the Settings > Purchasing "Require different approver" checkbox (require_different_approver on GlobalConfig), an inline banner on the Purchases tab when the mode is on, and a canApproveUnder4Eyes gate in history.ts so a session cannot approve a purchase it created itself. Mirrors the backend's requireDifferentApprover (issue #1005): mode off always allows; mode on requires a different, non-null creator. When RBAC would otherwise show Approve but 4-eyes blocks it, an inline "Awaiting different approver" badge replaces the button instead of silently hiding the action. Several existing history/settings tests needed updated api mocks and assertions since loadHistory() and saveGlobalSettings() now also read/ write require_different_approver.
…le (refs #1005) Adds two tests to settings.test.ts covering the load and save sides of the require_different_approver checkbox that 07585fa introduced: - populates the checkbox from config.global.require_different_approver on loadGlobalSettings (verifies the load-side deserialization). - sends require_different_approver: true through api.updateConfig when the box is checked and saveGlobalSettings runs (verifies the save-side serialization + payload shape). Both tests exercise the actual Settings module handlers rather than mocking them, matching the style of the surrounding tests in the file. Coverage gap noticed during the CR-fix review of #1500.
|
@coderabbitai full review |
✅ Action performedFull review finished. Your included review limit is currently reached under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 50 minutes. |
…1005) An adversarial review of PR #1500 found requireDifferentApprover wired into only 2 of 4 real approve/execute entry points: execute_mode= "direct" (unconditional self-authorization since the creator always equals the direct-executor on this path) and the SQS async approve worker (no creator comparison at all) could both bypass dual control. Move enforcement into purchase.Manager.ApproveAndExecute, the one choke point all four entry points funnel through, instead of relying on per-handler wiring. The new gate compares the acting actor's own UUID against the execution's creator UUID when available (the two session-based callers), falling back to a resolved-email comparison only for the token/SQS callers that have no actor UUID. The UUID tier also closes a second gap a follow-up adversarial pass caught in this same fix: a per-user API key session carries a real UUID but an empty Email, which an email-only comparison could never match against the creator's real email. Handler-level checks stay in place as defense-in-depth for the two already-gated paths.
Adversarial review follow-up: 4-eyes bypasses closed at the manager layerA follow-up adversarial (Opus) review of this PR found that
FixMoved enforcement into Identity comparison is two-tier for precision:
The two-tier design closes a second gap an independent Opus reviewer caught in this same fix during review: a per-user API key session carries a real user UUID but an empty TestsNew end-to-end regression tests drive the real production paths (not mocked-out shortcuts), each confirmed to fail on the pre-fix code and pass after:
Full repo: @coderabbitai review |
|
✅ Action performedReview finished.
|
|
@coderabbitai full review Context: the previous review passes on this PR were all throttled under CodeRabbit's Fair Usage limit (the 13:38 UTC full review returned a "review limit is currently reached" warning, and the 14:58 incremental command was a no-op that does not re-review already-reviewed commits). No walkthrough or inline review has actually landed for the current HEAD (97662ec), which added the manager-layer 4-eyes enforcement in internal/purchase/approvals.go and the new config.StoreInterface.GetUserEmailByID. Requesting a full review now that the rate-limit window has cleared so the security-critical enforcement change gets a real pass. |
|
✅ Action performedFull review finished. Your included review limit is currently reached under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 21 minutes. |
|
@coderabbitai full review |
✅ Action performedFull review finished. Your included review limit is currently reached under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 8 seconds. |
|
Merging. Verified the adversarial-review follow-up is fully implemented in code, not just described:
22 checks green, MERGEABLE/CLEAN. Merging. |
|
@coderabbitai full review |
✅ Action performedFull review finished. |
Summary
Implements the 4-eyes approval mode from #1005 end-to-end (backend + frontend). When
require_different_approveris set inglobal_config, the creator of a scheduled execution cannot approve it themselves — a second admin/purchaser must approve. Admin wildcard is NOT exempt (that's the whole point). NULL-creator legacy rows fail 403 with an admin-directed remediation message.Backend
require_different_approver BOOLEAN NOT NULL DEFAULT falsetoglobal_config.GlobalConfig.RequireDifferentApprover;GetGlobalConfig/SaveGlobalConfigread/write it; pgxmock fixtures updated.requireDifferentApproverhelper): wired into both approval paths —approvePurchaseViaSession(session cookie path) and the email-token path inapprovePurchase. Nil session with mode on fails 500 (fail-closed perfeedback_fail_closed_middleware).GetGlobalConfigcall in the session path.Frontend
saveGlobalSettings/loadGlobalSettings, round-trips throughupdateConfig/getConfig.canApproveUnder4Eyesgate inhistory.ts: when RBAC would show Approve but 4-eyes blocks it (same user is creator, mode on), an inline "Awaiting different approver" badge replaces the button rather than silently hiding the action.four-eyes-approval.test.ts(5 cases) + round-trip tests insettings.test.ts(2 cases) covering the settings toggle load + save;getConfigmock added to 8 siblinghistory-*test files whosejest.mock('../api', ...)blocks needed the new call.Test coverage
TestApprovePurchase_FourEyesMode_*× 7 inhandler_purchases_test.go;TestApprovePurchaseViaSession_*updated forGetGlobalConfigmock;store_postgres_pgxmock_test.gofixtures include the new column.four-eyes-approval.test.ts(235 lines, 5 cases);settings.test.ts(+41 lines, 2 round-trip cases); siblinghistory-*mocks updated.Reconciliation note
This PR absorbed uncommitted frontend work from a prior agent session. Follow-up issue #1501 (originally opened when the PR was backend-only) is superseded and will be closed.
Closes #1005.
Summary by CodeRabbit
New Features
Tests