Skip to content

fix(ri-exchange): run unattended exchanges as manual while 4-eyes is on - #1108

Draft
IAmJSD wants to merge 1 commit into
LeanerCloud:mainfrom
IAmJSD:fix/issue-679
Draft

IAmJSD wants to merge 1 commit into
LeanerCloud:mainfrom
IAmJSD:fix/issue-679

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes the gaps from the #673 review that are still open on main: unattended auto-mode exchanges under 4-eyes (A) and the test gaps (C). Part B (creator-less rows) was already handled by #989; details below.

Root cause (A)

executeRIExchangeReshape (internal/server/handler_ri_exchange.go) passed cfg.RIExchangeMode straight to exchange.RunAutoExchange. Any non-manual mode executes exchanges on the spot, and nothing on that path reads RequireDifferentApprover. With 4-eyes on, the scheduled ri_exchange_reshape task could therefore move money with no approver at all. Since #673 the direct execute endpoints refuse under 4-eyes, which left this path as the only way around the policy. The ladder exchange runner (exchangeRunnerAdapter in internal/server/ladder_write.go) has the same shape, and it always asks for auto mode.

Fix

  • New fourEyesExchangeMode (internal/server/ri_exchange_four_eyes.go): with 4-eyes on, every mode except manual runs as manual. That covers auto, "" and typos, because pkg/exchange treats every non-manual value as auto. A nil global config fails closed and is treated as 4-eyes on.
  • Scheduler: executeRIExchangeReshape uses the effective mode. A downgraded run raises pending records with approval tokens and sends the pending-approval email. Each exchange then goes through /api/ri-exchange/approve, which applies the existing 4-eyes check (fix(ri-exchange): require an identified approver for creator-less exchanges under 4-eyes #989: an identified approver is required, and token-only requests are refused).
  • Ladder runner: exchangeRunnerAdapter.RunAutoExchange builds its AWS clients and hands them to a new run, which reads the global config and applies the same downgrade. If the config can't be read, run returns an error before any AWS call (fails closed). Client construction is unchanged apart from moving into riExchangeClients, which makes run testable with mocks.
  • The stored ri_exchange_mode is not modified. Turning 4-eyes off restores auto behavior at the next run.
  • Doc-only: the RequireDifferentApprover comment in internal/config/types.go and the PUT /api/ri-exchange/config description in openapi.yaml.

Owner decision needed (issue question 1)

I implemented the conservative option: under 4-eyes, auto mode does not bypass approval and runs as manual. I chose a runtime downgrade over refusing the config write for two reasons. It also covers turning 4-eyes on while auto is already armed. And it fails closed whatever order the two settings are written in.

Not made configurable. An "auto is exempt from 4-eyes" opt-out would need a new global_config column, a migration, store read/write and API plumbing. It would also add another 4-eyes bypass knob. That isn't cheap, and it isn't something I should add without your call. If you want auto to stay exempt, say so and it can be a follow-up (or this PR can be closed).

Part B (issue question 2): already handled, no change here

#989 (4e23d2c) changed requireDifferentRIExchangeApprover. Under 4-eyes, a creator-less (system) exchange now needs a resolved session, so an anonymous email-token approval is refused, and a config-load failure fails closed. An identified approver can still approve it. This matches your 2026-10-17 decision for system-created purchases on #233 (Option B, shipped in #1071): the system is the first eye and an identified human the second. This PR leaves that untouched. If you want creator-less exchanges denied outright under 4-eyes, note that every real exchange row is creator-less, so all exchanges would become unapprovable while the mode is on.

Part C: tests

  • TestExecuteExchange_FourEyesRefusesDirectExecute and TestExecuteExchange_FourEyesPolicyLookupErrorFailsClosed now install a spy executeExchangeFn (spyOnAWSExchange) that would report success, and assert zero calls. Before, they relied on the real executor failing on missing AWS credentials.
  • New TestExecuteAzureExchange_FourEyesPolicyLookupErrorFailsClosed: a GetGlobalConfig error must give a non-client (500) error containing 4-eyes policy check, with no CalculateExchange or ExecuteExchange call on the strict ops-client mock.

Verification

All Go commands ran on macOS with AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null, because this machine's default AWS profile is invalid. The evidence is unit/mock-based: no real AWS or Azure calls and no real Postgres. This PR has no DB or schema change. The 4-eyes flag is read once per run from the global config, so there's no new race to test against Postgres.

New server tests (internal/server/ri_exchange_four_eyes_test.go):

  • TestExecuteRIExchangeReshape_FourEyesDowngradesAutoToManual (modes auto, "", Auto): zero executes, result mode manual, one pending record with an approval token, approval email sent, no completed email.
  • TestExecuteRIExchangeReshape_FourEyesOffAutoStillExecutes (control).
  • TestExchangeRunnerAdapter_FourEyesDowngradesLadderReshape (pending record keeps its LadderRunID) and TestExchangeRunnerAdapter_FourEyesOffLadderStillExecutes (control).
  • TestExchangeRunnerAdapter_PolicyLookupErrorFailsClosed: error returned, no RI listing, no execute, no record.
  • TestFourEyesExchangeMode (table, including nil config).

Fail-before / mutation probes. Each probe was applied to the fixed tree and then reverted. Command: go test ./internal/server/ -run 'FourEyes|ExchangeRunnerAdapter|ExecuteRIExchangeReshape' -count=1.

  1. Fix removed (scheduler passes the stored mode, ladder has no gate): FourEyesDowngradesAutoToManual, FourEyesDowngradesLadderReshape and PolicyLookupErrorFailsClosed fail.
  2. Helper never downgrades: both downgrade tests and TestFourEyesExchangeMode fail.
  3. Ladder swallows the config error: PolicyLookupErrorFailsClosed fails.
  4. Nil config treated as off: TestFourEyesExchangeMode fails.

API tests with requireNoDirectExchangeUnderFourEyes made a no-op, each run alone with go test ./internal/api/ -run '^<name>$':

  • TestExecuteExchange_FourEyesRefusesDirectExecute: FAIL ("no exchange may be submitted while 4-eyes is on").
  • TestExecuteExchange_FourEyesPolicyLookupErrorFailsClosed: FAIL ("an unreadable 4-eyes policy must not let the exchange run").
  • TestExecuteAzureExchange_FourEyesPolicyLookupErrorFailsClosed: FAIL (the strict mock rejects the unexpected CalculateExchange).

All pass with the fix.

Checks run

  • go build ./...: OK.
  • go vet ./internal/server/ ./internal/api/ ./internal/config/, plus -tags integration for server and api: OK.
  • go test -count=1 for ./internal/server/, ./internal/api/, ./internal/mocks/ and ./internal/config/: all ok. The targeted tests also pass under -race.
  • golangci-lint v2.10.1 run ./internal/server/... ./internal/api/... ./internal/config/...: 0 issues.
  • gocyclo -over 10 -ignore "_test\.go" on the changed files: silent.
  • scripts/gosec-hook.sh on the changed packages: clean.
  • git-secrets scan of the changed files, in an isolated temp repo set up by scripts/setup-git-secrets.sh: clean. scripts/test-git-secrets-allowlist.sh: 42 PASS, 0 FAIL.
  • openapi.yaml parses. git diff --check: clean.

Not run locally: the full repo-wide race suite (disk constraints; CI covers it), and anything against a real AWS account.

Out of scope / notes

  • UI: the settings page still shows the stored auto mode while 4-eyes makes it run as manual. An "effective mode" field on GET /api/ri-exchange/config, plus the frontend change, could be a follow-up. Frontend work is off-limits for me right now.
  • Ladder: a downgraded ladder reshape creates pending rows but sends no email, because the ladder runner has no notification wiring. Today nothing in production calls ReshapeBuffer, so this is defense in depth for when it gets wired.
  • Timing: a scheduled run uses the config it read at start. Turning 4-eyes on mid-run takes effect at the next run.
  • API-key-created purchases with a null creator are fix(purchase): API-key-created pending purchases have a null creator and can never be approved under four-eyes #677, a separate issue and PR.

Maintainer: please mirror the issue's triage labels on this PR: priority/p2, severity/medium, urgency/this-quarter, impact/few, effort/m, type/security, triaged.

Closes #679

🤖 Generated with Claude Code

With RIExchangeEnabled and a non-manual RIExchangeMode, the scheduled
ri_exchange_reshape task called exchange.RunAutoExchange in auto mode and
executed exchanges with no approver, even with RequireDifferentApprover
on. The direct execute endpoints already refuse under 4-eyes (LeanerCloud#673), so
auto mode was the remaining path that moved money with no second person.

With 4-eyes on, every non-manual mode ("auto", "" and typos, which
pkg/exchange all treats as auto) now runs as "manual": the run only
raises pending records with approval tokens, sends the pending-approval
email, and each exchange then goes through the approve endpoint and its
4-eyes check. The stored mode is unchanged, so turning 4-eyes off brings
auto back. The ladder exchange runner gets the same downgrade; it reads
the global config itself and fails closed (no AWS call) when it cannot.

Also tightens the LeanerCloud#673 tests: the AWS direct-execute 4-eyes tests now
spy on the exchange executor and assert it is never called, and the
Azure global-config lookup failure has a fail-closed test.

Closes LeanerCloud#679

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD IAmJSD added priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged urgency/this-quarter Within the quarter impact/few Limited audience effort/m Days type/security Security finding labels Oct 19, 2026
@coderabbitai

coderabbitai Bot commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

@cristim

cristim commented Oct 20, 2026

Copy link
Copy Markdown
Member

VERDICT: APPROVE 360a0dc

Reviewed head 360a0dc (1 commit, 379 changed lines, under the 400 cap) against origin/main b6a0649. The base is 40 commits stale; git merge-tree against origin/main is clean, and all tests below ran on the merged tree.

Findings (none blocking):

  • process: issue sec(ri-exchange): auto mode and nil-creator rows bypass 4-eyes; tighten #673 tests #679 asks the owner to choose (question 1) between enforcing 4-eyes on auto mode and keeping auto exempt. The PR implements the conservative option and says so. The code is correct for that option. Merging it still needs the owner's explicit answer on sec(ri-exchange): auto mode and nil-creator rows bypass 4-eyes; tighten #673 tests #679.
  • nit internal/server/ri_exchange_four_eyes_test.go:236-237 - the table covers "", "Auto" and "auto", but not "Manual". A mutant that passes the mode through case-insensitively at ri_exchange_four_eyes.go:25 (strings.EqualFold(mode, manual)) survives every test. pkg/exchange@541e05e5 auto.go:274 routes only the exact "manual" to the pending path, so that mutant would auto-execute a stored "Manual" under 4-eyes. Add a {on, "Manual", "manual"} row.
  • nit (documented in the PR) internal/server/ladder_write.go:62-70 - a downgraded ladder reshape creates pending rows with no email, and the next ladder run cancels them (CancelPendingExchangesByOrigin). That is harmless today because nothing in production calls ReshapeBuffer.

Attacked:

  • Every unattended execution path on the merged tree. The only callers of exchange.RunAutoExchange are handler_ri_exchange.go:111 (scheduled ri_exchange_reshape, now fourEyesExchangeMode(cfg, cfg.RIExchangeMode)) and ladder_write.go:86 (gated in run before any AWS call). The direct execute endpoints (AWS, Azure) already refuse under 4-eyes (requireNoDirectExchangeUnderFourEyes). No other scheduler task executes exchanges.
  • Pinned go lib pkg@v0.0.0-20261018003623-541e05e5fd29: the exact "manual" goes to processManualExchange, and every other value auto-executes. The downgrade therefore covers "", "Auto" and typos. go#578 (fail closed on unknown modes) is not in this pin. Once the pin is bumped past it, the PR is still correct: the downgrade only ever emits "manual".
  • fix(config): validate RIExchangeMode on PUT /api/config #1196 (PUT /api/config does not validate RIExchangeMode): with 4-eyes on, any stored bad value runs as manual. With 4-eyes off, behavior is unchanged. fix(config): validate RIExchangeMode on PUT /api/config #1196 stays open on its own.
  • Human approval is enforced, not just logged. Downgraded rows have a nil creator. requireDifferentRIExchangeApprover (handler_ri_exchange.go:2611) refuses a token-only approval under 4-eyes, and the approve path sends a pending-approval email when result.Mode == "manual".
  • Fail-closed: a nil config is treated as 4-eyes on. A ladder GetGlobalConfig error returns before the RI listing.
  • Mutation probes on a scratch copy of the merged tree (each reverted):
    1. scheduler passes the stored mode: FourEyesDowngradesAutoToManual fails (3 subtests).
    2. nil cfg treated as off: TestFourEyesExchangeMode/nil_config_fails_closed fails.
    3. ladder config error swallowed and run as auto: PolicyLookupErrorFailsClosed fails.
    4. ladder gate removed: FourEyesDowngradesLadderReshape fails.
    5. requireNoDirectExchangeUnderFourEyes made a no-op: all 3 API 4-eyes tests fail when run one at a time.
    6. case-insensitive "manual": survives (nit above).

Local results (merged tree 301be9e4, macOS, AWS creds disabled): go build ./... OK; go vet with and without -tags integration on server/api/config OK; go test -run 'FourEyes|ExchangeRunnerAdapter|ExecuteRIExchangeReshape|RIExchange' ./internal/server/ 30 passed; go test -run 'FourEyes|RIExchange|Exchange' ./internal/api/ 373 passed. CI on the PR head is all green, but it ran on the stale base.

@cristim

cristim commented Oct 20, 2026

Copy link
Copy Markdown
Member

Independent review 2 (claude-fleet, cold): APPROVE 360a0dc

Findings (none blocking):

  • [non-blocking] internal/server/ri_exchange_four_eyes.go:28 vs internal/api/handler_ri_exchange.go:2572 - The new helper treats a nil GlobalConfig as 4-eyes ON. The direct-execute gate (cfg != nil && cfg.RequireDifferentApprover) treats it as OFF.
    • Inconsistent, but nil without an error does not happen in practice, because the Postgres store returns defaults when no row exists.
  • [non-blocking] internal/api/openapi.yaml:1357 says only "the scheduler" runs auto as manual. The ladder reshape is downgraded too, as the types.go comment says. This is a doc gap only.
  • [non-blocking] The ladder dry-run now fails when the global config cannot be read (ladder_write.go:68-71). That is fail-closed and acceptable.
  • Part B is deferred to the owner, as the PR body states. Nothing in this PR depends on it.

Root cause:

  • pkg/exchange auto.go:274 routes only the literal "manual" to processManualExchange; every other value executes.
  • The helper downgrades every value except "manual", so "", "Auto" and typos are covered.
  • Both RunAutoExchange call sites, on head and on origin/main (server/handler_ri_exchange.go:110, server/ladder_write.go:86), are gated. There is no other caller in internal/ or cmd/.

Attacked:

Verified locally, on git archive exports with no cloud credentials, under the heavy lock:

  • Head:
    • go build ./...: OK.
    • go vet on server, api and config, and with -tags integration on server and api: OK.
    • gofmt -l on the 7 changed files: clean.
    • go test -count=1 on server, api and config: ok.
    • go test -race -run 'FourEyes|ExchangeRunnerAdapter|ExecuteRIExchangeReshape' ./internal/server/: ok.
  • Merged tree: build, vet and go test -count=1 on server, api and config: ok.
  • No migration, frontend or terraform changes.

Reviewed by an independent agent with no author context (second reviewer). Review only: nothing was pushed, and the GitHub review state is unchanged.

This branch has not been deployed

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

Labels

effort/m Days impact/few Limited audience priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/security Security finding urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sec(ri-exchange): auto mode and nil-creator rows bypass 4-eyes; tighten #673 tests

2 participants