Repository navigation
fix(ri-exchange): require an identified approver for creator-less exchanges under 4-eyes - #989
Merged
Merged
Conversation
…hanges under 4-eyes Every ri_exchange_history row is written by the scheduler or the ladder engine through exchangeToConfigRecord, and pkg/exchange.ExchangeRecord has no creator field, so created_by_user_id is always NULL. The NULL-creator early return in requireDifferentRIExchangeApprover skipped the 4-eyes gate entirely, including its fail-closed "no session" check: with RequireDifferentApprover on, an anonymous email-token POST could approve and execute any production exchange with no recorded actor, contradicting the documented contract in openapi.yaml. A creator-less exchange now needs a resolved session when 4-eyes mode is on (same 500 and message as creator-bearing rows), and a config-load failure fails closed. With a session it stays approvable by any single authorized approver: there is no human requester to differ from, and denying it would make every exchange unapprovable (the purchase-side LeanerCloud#233 failure). Mode off is unchanged. Refs LeanerCloud#518 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
cristim
reviewed
Oct 16, 2026
cristim
left a comment
Member
There was a problem hiding this comment.
Independent review verdict: CLEAN at 42b852e
Reviewer: fresh Claude agent (Sonnet, high effort), not the author, cold brief, acting as a security reviewer. The PR body was treated as a claim. Coordinator: claude-cc-reviewer.
Blocking findings: none. The route that let a token-only request approve an exchange under four-eyes is closed by this PR.
Route table:
| Route | Where | Status |
|---|---|---|
POST /api/ri-exchange/approve/{id}, token only |
router.go:318, handler_ri_exchange.go:2243 → approveRIExchangeViaToken :2285 |
Covered (this was the hole). requireDifferentRIExchangeApprover (:2546) now returns 500 with no session under four-eyes |
| Same route with a session | :2332 |
Covered: CSRF, then requireSession, then the same helper |
| Session without the approve permission, plus a token | falls through to the token branch | Covered: the approver is identified and stamped (note 2) |
POST /api/ri-exchange/reject/{id} |
:2733 |
Unaffected by design: a token-only reject is allowed, which is the safer direction |
/api/ri-exchange/execute, azure-instances exchange |
:1207, :2518 |
Already refused (403) under four-eyes via requireNoDirectExchangeUnderFourEyes |
| Scheduler and ladder auto modes | internal/server/handler_ri_exchange.go:95 (RunAutoExchange) |
Unaffected by design: unattended and operator-selected (note 1) |
Behaviour checks:
- A recorded creator is unchanged: 500 with no session, 403 for the creator.
- Four-eyes off is unchanged: token-only approval still works, and the integration control subtest confirms it.
- The token is validated first (
:2565), so the 500 is visible only to someone holding a valid token. Nothing extra leaks. - A global-config load error fails closed.
config/types.gochanges a comment only.openapi.yamlmatches the behaviour.
Non-blocking notes:
- Pre-existing policy question for the maintainer: the scheduler and ladder auto modes execute exchanges with no human approver even when four-eyes is on. Please confirm "auto = unattended" is intended under four-eyes.
- A signed-in user without the approve permission who holds a valid token can approve under four-eyes (
:2268, 2275). The approver is identified and stamped, which matches the existing behaviour for rows that have a creator. - The refusal is HTTP 500 for what is really an authentication failure. That is consistent with the purchase-side wording.
What was attacked and how (local macOS; no real exchange):
go vet ./internal/...is clean.go test -race -count=1 ./internal/api/... ./internal/server/...passes 3934. The only RI-exchange-related skip is unrelated (TestRevokePurchase_FourEyesApproval).go test -tags integrationonTestApproveRIExchange_Integration_SystemCreatedFourEyesran against Docker Postgres: both subtests pass, with no skip.- Mutation probes:
- Restoring the early nil return (fail-before) fails
FourEyes,ConfigLoadFailsClosed,StatusCodesand the integration test. - Allowing no session on creator-less rows fails
FourEyes,StatusCodesand the integration test. - Dropping the approver stamp fails three unit tests. The integration test doesn't assert the stamp.
- Removing the session allow fails two unit tests.
- Restoring the early nil return (fail-before) fails
Gate:
- Labels are mirrored from #518.
- CI must be green at this SHA.
- The draft must be marked ready, and CodeRabbit must review once it is.
cristim
marked this pull request as ready for review
October 16, 2026 15:25
This was referenced Oct 19, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #518. This fixes the parts of the issue that #602 left open, legs (a) and (b) from the status comment. #602 already shipped leg (c), token-path attribution (
transitioned_byandapproved_bywhen a session resolves). This PR does not touch it.Root cause
Every
ri_exchange_historyrow is written by the scheduler (ri_exchange_reshape) or the ladder engine throughconfigExchangeStoreAdapter/exchangeToConfigRecord.pkg/exchange.ExchangeRecord(cloud-commitments-goad67030387e4) has no creator field, socreated_by_user_idis always NULL. No API route creates a pending exchange:/api/ri-exchange/executeruns directly and is already refused under 4-eyes.requireDifferentRIExchangeApproverreturnednilstraight away for a NULL creator. That return also skipped the shared helper's fail-closed "no session" check. The result: withRequireDifferentApproveron, an anonymous POST carrying only the email token could approve and execute any production exchange, with no recorded actor.openapi.yamlalready promised that "a token request with no session fails with 500" under 4-eyes. That was only true for creator-bearing rows, and no such rows exist in production.Fix
In
requireDifferentRIExchangeApprover(internal/api/handler_ri_exchange.go):requireDifferentApproverruns: no session gives 500, the creator gives 403.pendingand is not attributed.transitioned_byandapproved_bythrough fix(ri-exchange): attribute token-path approvals to the resolved session user #602.Comment-only updates:
config.RIExchangeRecord.CreatedByUserIDnow points at the actual policy rather than #233 (which covers purchases), andexchangeToConfigRecorddocuments that leaving the creator nil is intentional.openapi.yamlnow describes creator-less behaviour.Decision for the maintainer: which creator should scheduler exchanges carry? (leg a)
The status comment asked which creator scheduler exchanges should carry: a system user, the config owner, or NULL by design. This PR picks NULL by design, for these reasons:
ladder_configs, and neither records who saved them today. Inventing one needs new columns and a policy, and it is easy to bypass: someone else just re-saves the config.If you prefer config-owner attribution, that is a follow-up: owner columns on the config tables, a creator field in
pkg/exchangeor an adapter-side stamp, and a migration. I can file it.Who can approve their own exchange: before and after
Users cannot create pending exchanges, so "own exchange" only applies to a creator-bearing row, which production does not write. That case is unchanged: the creator is refused under 4-eyes. What changes is the creator-less row, which is every real row:
Migration / backfill
No migration.
created_by_user_idalready exists (000054), and the store already writes and reads it. No writer has ever set it on an exchange row, so every existing row is system-created and NULL. NULL now means "system-created" for those rows, which is accurate. No backfill is needed. The only effect on existing pending rows: under 4-eyes they now need a signed-in approver.Verification
All Go commands ran with
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null, on macOS against base2ef2f5d2. No real AWS: the integration test injectsexecuteExchangeFnwith a fake that fails.go test ./internal/api/ -run 'TestApproveRIExchange_FourEyes|TestRequireDifferentRIExchangeApprover|TestHandleRequest_ApproveRIExchange_StatusCodes' -count=1fails on:TestApproveRIExchange_FourEyes/automated_exchange:_token_without_session_fails_closedTestRequireDifferentRIExchangeApprover_ConfigLoadFailsClosedTestHandleRequest_ApproveRIExchange_StatusCodes/500_4-eyes_on_and_token_without_session_on_a_creator-less_exchangeTestHandleRequest_RejectRIExchangegivesok.go test -tags integration ./internal/api/ -run TestApproveRIExchange_Integration_SystemCreatedFourEyes -count=1 -vpending,approved_byis NULL and the executor is never called. The control run with 4-eyes off approves the same seed.session != nilallow: 3 failures (session and token+session approvals of creator-less rows).ConfigLoadFailsClosedfails.go build ./...OK.go vet ./internal/api/ ./internal/server/ ./internal/config/andgo vet -tags integration ./internal/api/OK.go test ./internal/api/ ./internal/server/ ./internal/config/ -count=1allok.golangci-lint run --allow-parallel-runners(v2.10.1) on./internal/api/... ./internal/server/... ./internal/config/...reports 0 issues.gocyclo -over 10on the changed non-test files prints nothing.git-secrets --scan(AWS patterns plus the repo's.gitallowed) on the changed files is clean, andbash scripts/test-git-secrets-allowlist.shreports 42 PASS, 0 FAIL.git diff --checkis clean andopenapi.yamlparses as YAML.Not verified: a deployed environment or a real AWS exchange. That is out of scope by design.
Out of scope
pkg/exchange.ExchangeRecordin cloud-commitments-go.Maintainer: please mirror the issue's labels onto this PR:
priority/p2,severity/medium,urgency/this-sprint,impact/few,effort/m,type/bug,triaged.🤖 Generated with Claude Code
Summary by CodeRabbit