Skip to content

fix(api): enforce four-eyes on RI exchange approval - #501

Merged
cristim merged 2 commits into
mainfrom
fix/ri-exchange-four-eyes
Oct 5, 2026
Merged

cristim merged 2 commits into
mainfrom
fix/ri-exchange-four-eyes

Conversation

@cristim

@cristim cristim commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

What

With RequireDifferentApprover on, the creator of an RI exchange could approve and execute it themselves. Neither branch of approveRIExchange (session or email token) called requireDifferentApprover, which only the purchase approval paths used.

  • requireDifferentApprover now takes (subjectID, creatorUserID *string) instead of a *config.PurchaseExecution, so the RI exchange path reuses the same policy. Purchase behaviour is unchanged (separate refactor commit).
  • approveRIExchangeViaSession and approveRIExchangeViaToken both run the check after authorization and before the pending -> processing transition. The token branch resolves the caller's session and fails closed (500) when the mode is on and no session resolves, matching approveViaToken for purchases.
  • The token branch matters on its own: a 403 from the session branch falls through to the token branch when a token is in the URL, so a session-only fix is bypassable (the mutation run below shows this).

Nil creator (decision left open)

Every RI exchange written today comes from the automated pipeline (exchange.auto via configExchangeStoreAdapter, which never sets CreatedByUserID), so production exchange rows all have a NULL creator. The purchase policy denies a NULL creator with 403. Applying that here would make every RI exchange unapprovable while 4-eyes is on, the same failure #233 describes for scheduler-created purchases. This PR therefore leaves creator-less exchanges approvable as before and pins that in a test. What 4-eyes should do for creator-less rows is the open question in #233.

How verified

Toolchain: GOTOOLCHAIN=go1.26.6 GOWORK=off GOFLAGS='-p=2 -count=1' AWS_EC2_METADATA_DISABLED=true

  • New TestApproveRIExchange_FourEyes (9 subtests). With the RI exchange change reverted (helper refactor kept), 5 fail and 4 pass:
    --- FAIL: TestApproveRIExchange_FourEyes/session:_creator_with_approve-own_denied
    --- FAIL: TestApproveRIExchange_FourEyes/session:_admin_creator_denied
    --- FAIL: TestApproveRIExchange_FourEyes/session:_creator_holding_the_email_token_still_denied
    --- FAIL: TestApproveRIExchange_FourEyes/token:_creator_session_without_approve_rights_denied
    --- FAIL: TestApproveRIExchange_FourEyes/token:_no_session_fails_closed
    Error: An error is expected but got nil.
    
  • Mutation, session call removed: the 3 session: denial subtests fail. Token call removed: creator_holding_the_email_token, token: creator session and token: no session fail. Nil-creator skip removed: automated exchange without creator stays approvable fails.
  • With the fix: go build ./..., go vet and go vet -tags integration on internal/api, internal/purchase and internal/server are clean. go test passes for all three packages (2445 tests in internal/api). golangci-lint run ./internal/api/... reports no issues.
  • Frontend: handleRIExchangeApproveClick in frontend/src/riexchange.ts shows the backend error string in a toast, so the creator sees "Failed to approve exchange: approval declined: 4-eyes mode requires a different approver than the requester".

Deliberately not in this PR

Refs #221 (production exchange rows have no creator recorded, so the control only applies to rows that carry one; see "Nil creator")

The 4-eyes check only needs the subject ID and the creator's user ID, not
a whole PurchaseExecution. Taking those two values lets the RI exchange
approval path reuse the same policy instead of duplicating it.

No behaviour change for purchase approvals; log lines now use a neutral
"4-eyes[<id>]" prefix.

Refs #221
With RequireDifferentApprover on, the user who created an RI exchange
could approve and execute it themselves: neither the session branch nor
the email-token branch of approveRIExchange called
requireDifferentApprover.

Both branches now run the same check as purchase approvals before the
pending -> processing transition. The token branch resolves the
caller's session for the comparison and fails closed (500) when the mode
is on and no session is present, as the purchase token path does. The
token branch also matters because a session-branch 403 falls through to
it when a token is supplied, so a session-only fix would be bypassed.

Exchanges with no recorded creator (every exchange the automated
pipeline writes today) keep their current behaviour. The purchase
policy denies a nil creator, which applied here would make every RI
exchange unapprovable while the mode is on; that decision is open in
issue #233.

Closes #221
@cristim cristim added severity/medium Moderate harm urgency/this-sprint Within the current sprint triaged Item has been triaged priority/p1 Next up; this sprint impact/many Affects most users effort/s Hours type/bug Defect labels Oct 5, 2026
@coderabbitai

coderabbitai Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

This review includes 4 billable files and costs up to $1.00.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Or wait 4 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 2 included reviews currently available. Your 54 included PR review attempts over the past 7 days set your current allowance at 2 reviews per hour.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: LeanerCloud/cloud-commitments-platform/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Essentials
  • Run ID: 76e9ba18-b6c9-46cb-b49d-8163e6a82238
📥 Commits

Reviewing files that changed from the base of the PR and between da5da2b and a01bb1a.

📒 Files selected for processing (4)
  • internal/api/handler_purchases.go
  • internal/api/handler_purchases_test.go
  • internal/api/handler_ri_exchange.go
  • internal/api/handler_ri_exchange_test.go
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

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

Labels

effort/s Hours impact/many Affects most users priority/p1 Next up; this sprint severity/medium Moderate harm triaged Item has been triaged type/bug Defect urgency/this-sprint Within the current sprint

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant