Skip to content

sec(exchange): gate the config write that arms unattended RI exchange - #1773

Merged
cristim merged 1 commit into
mainfrom
sec/1765-auto-exchange-write-gate
Aug 10, 2026
Merged

cristim merged 1 commit into
mainfrom
sec/1765-auto-exchange-write-gate

Conversation

@cristim

@cristim cristim commented Aug 8, 2026 •

Copy link
Copy Markdown
Member

Closes #1765

The defect

execute:ri-exchange is carved out of admin:* (#1644, PR #1758) so "a compromised admin account alone cannot drain commitments." update:config is deliberately not carved out — PR #1758's own control test asserts admin:* must keep it.

But a single update:config write could set ri_exchange_mode: "auto" plus ri_exchange_enabled: true, and the scheduled TaskRIExchangeReshape → RunAutoExchange → processAutoExchange then executed against the provider with no execute:ri-exchange check anywhere on that path. The carve-out's guarantee did not hold via that route.

The fix

Arming auto mode is functionally equivalent to pre-authorizing every future exchange, so the transition into that state now requires the caller to also hold execute:ri-exchange. The existing verb is reused, not reinvented.

Both write paths, one helper

PUT /api/config (updateConfig) unmarshals the body onto the whole GlobalConfig, so it reaches ri_exchange_mode / ri_exchange_enabled exactly as effectively as the dedicated PUT /api/ri-exchange/config (updateRIExchangeConfig). A gate on the dedicated endpoint alone would have looked complete and would not have been. One helper — requireRIExchangeAutoModeGrant — is called from both. Both already resolved a session; one merely discarded it with _.

"Armed" mirrors the consumer, not the field's nominal domain

This is the part that would have shipped a hole. processRecommendation (pkg/exchange/auto.go:248) routes to processManualExchange only on the literal "manual" and sends every other value to processAutoExchange. And GlobalConfig.Validate never constrains RIExchangeMode — verified: the field appears only in the struct, the Postgres scan, and the DB default.

So via PUT /api/config, a mode of "Auto", "automatic" or "" arms unattended execution just as effectively as "auto", while a gate written as Mode == "auto" would wave it straight through — on precisely the route the issue calls the headline. The predicate is therefore Enabled && Mode != "manual", and TestRIExchangeAutoModeGate_NonManualModeIsArmed pins it against five such strings.

The dedicated endpoint's own validate() restricts mode to manual|auto, so that shape cannot reach it. That asymmetry is exactly why the gate reads the field the scheduler reads rather than the one the stricter endpoint validates.

Only the escalation is gated

An update:config-only operator keeps everything except the one write:

transition update:config alone
set mode: "manual" allowed
ri_exchange_enabled: true while mode stays manual allowed — the scheduler only raises Pending approvals, no money moves
tune utilization / lookback, any unrelated config write allowed
lower a spend cap, disarm, idempotent re-save while armed allowed (de-escalations)
arm auto (enabled + non-manual mode) refused (403)
raise a spend cap while already armed refused (403)

Cap raises are gated because the caps are plain fields on the same struct — the same actor would otherwise set both the switch and its own bound in one request.

Two smaller correctness points

  • The check runs inside the UpdateGlobalConfigAtomic closure, so it compares against the same advisory-locked row the write lands on. Checking beforehand would race a concurrent writer between the check and the write.
  • A refusal is returned as its own 403 rather than wrapped in fmt.Errorf("failed to save config: %w", …), which would have blamed the store for an authorization decision. (IsClientError uses errors.As, so the status survived wrapping — but the message did not.)

Tests — four quadrants × both endpoints

Every case in TestRIExchangeAutoModeGate runs against both handlers as subtests, and each asserts the error and whether the write reached the store — asserting only the error would pass against a handler that returned one after already saving.

A refusal-only suite passes just as well against a gate that refuses every config write, which would silently strip the "configures but does not execute" operator role this fix is careful to preserve. Hence the three "still allowed" quadrants, plus _UnrelatedConfigWriteUnaffected and _ArmedIdempotentRewriteAllowed as scope controls.

Mutation-tested — six mutations, all killed by named tests

mutation killed by
M1 — gate removed from PUT /api/config only (the "looks complete" fix) the 3 refusal quadrants' generic PUT /api/config subtests + all 5 NonManualModeIsArmed cases
M2 — gate removed from PUT /api/ri-exchange/config only the same 3 quadrants' dedicated subtests
M3 — predicate narrowed to Mode == "auto" all 5 NonManualModeIsArmed cases
M4 — cap raises while armed no longer gated both cap-raise quadrants, both endpoints
M5 — gate applied to every config write (refuse-everyone) all 4 "still allowed" quadrants, both endpoints
M6 — gate checks update:config instead of execute:ri-exchange all refusal quadrants + NonManualModeIsArmed

M1 and M2 are the pair that matters: each kills only on its own endpoint's subtests, which is the evidence that the two-route coverage is real rather than incidental.

Out of scope, flagged not fixed

  • Frontend UX honesty. frontend/src/riexchange.ts's renderAutomationSettings renders the "Enable Automated Exchange" toggle and Mode select with zero canAccess gating; the only friction is a client-side confirm dialog, which is UX and not authorization. Backend enforcement is what matters and is what this PR delivers — a non-RI-Exchanger will now get a 403 rather than a silent success. Disabling those controls for non-RI-Exchanger users is a follow-up; isRIExchanger() (added in sec(auth): carve execute:ri-exchange out of admin:* and seed the granting group #1758's F2 round, frontend/src/permissions.ts) is the helper for it.
  • #1768 (the ladder equivalent) is untouched, per its own issue — the pattern is identical but currently inert.
  • The scheduled path still never consults auth.PermissionConstraints, so it has none of the account/provider/region/amount scoping requirePermissionConstraints enforces on the direct handler. That is a separate categorical gap noted in sec(exchange): scheduled auto-exchange bypasses the execute:ri-exchange carve-out via update:config #1765's analysis; this PR closes the arming route, not that.

Known coverage residual

The matrix drives both routes with an admin:* principal, which is the #1765 threat model and what /api/config's AuthAdmin gate requires. One shape is therefore not covered by any quadrant: a plain non-admin session holding only update:config against the dedicated AuthUser route. The API-key tests do exercise a non-admin credential on that route, so the shape is not wholly untested. Stated here rather than papered over; the matrix is deliberately not expanded for it.

Gates

gate result
gofmt -l . clean (0 files)
go build ./... / go vet ./... exit 0 / exit 0
gocyclo -over 10 -ignore "_test\.go" . exit 0, empty
go test -race -count=1 ./... (root) exit 0, 32 ok, 0 FAIL
golangci-lint v2.10.1, root module, comm set diff vs origin/main base exit 0 / 0 issues., head exit 0 / 0 issues. — zero new, zero removed

The lint gate caught three findings on the first pass and they were fixed before commit: a govet shadow from hoisting err to function scope in updateConfig (the same class currently blocking #1770), an errcheck on a discarded *clientError, and a misspell.

Summary by CodeRabbit

  • Access Control
    • Added authorization checks for enabling unattended RI exchanges or increasing spending caps while auto mode is active.
    • Manual-mode updates, cap reductions, and unchanged auto-mode settings remain available with existing configuration permissions.
  • Bug Fixes
    • Prevented unauthorized auto-mode changes from being saved through either configuration endpoint.
  • Tests
    • Added coverage for permission combinations, mode changes, cap updates, rejected requests, and unaffected configuration changes.

@coderabbitai

coderabbitai Bot commented Aug 8, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The change adds authorization checks for RI exchange auto-mode configuration. Arming auto mode or increasing spending caps requires execute:ri-exchange. Manual-mode changes, reductions, disarming, and unchanged armed configurations remain available with update:config.

Changes

RI exchange authorization

Layer / File(s) Summary
Auto-mode authorization rules
internal/api/handler.go
The gate snapshots RI exchange settings, identifies armed configurations, and requires execute:ri-exchange for arming or increasing spending caps.
Configuration handler integration
internal/api/handler_config.go, internal/api/handler_ri_exchange.go
Both configuration endpoints compare previous and proposed settings inside atomic updates and return authorization errors directly.
Authorization and persistence tests
internal/api/ri_exchange_automode_gate_test.go
Tests cover permission combinations, API-key permissions, non-manual modes, denied persistence, unrelated writes, and idempotent armed updates.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Possibly related issues

Possibly related PRs

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ConfigurationHandler
  participant AuthorizationGate
  participant ConfigStore
  Client->>ConfigurationHandler: submit RI exchange configuration
  ConfigurationHandler->>ConfigStore: snapshot current state under lock
  ConfigurationHandler->>AuthorizationGate: validate proposed state and credential
  AuthorizationGate-->>ConfigurationHandler: allow or return 403
  ConfigurationHandler->>ConfigStore: persist allowed configuration
  ConfigurationHandler-->>Client: return update result
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: gating configuration writes that arm unattended RI exchange.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch sec/1765-auto-exchange-write-gate

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

@cristim cristim added triaged Item has been triaged priority/p1 Next up; this sprint severity/high Significant harm urgency/this-sprint Within the current sprint impact/many Affects most users effort/m Days type/security Security finding labels Aug 8, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
internal/api/ri_exchange_automode_gate_test.go (2)

103-104: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Correct the comment subject.

The comment starts with "armed is the stored state". The function is armedConfig.

✏️ Proposed wording fix
-// armed is the stored state an attacker would be escalating FROM or operating
-// within: unattended execution already switched on.
+// armedConfig is the stored state an attacker would be escalating FROM or
+// operating within: unattended execution already switched on.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/api/ri_exchange_automode_gate_test.go` around lines 103 - 104,
Update the comment immediately above armedConfig to refer to armedConfig as the
stored state, replacing the incorrect “armed” subject while preserving the rest
of the explanation.

192-218: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a positive control for the cap-raise transition.

The matrix gates cap raises with autoGateConfigOnly only. No case raises a cap while holding execute:ri-exchange. Without that case, a gate that refuses every cap raise regardless of permission still passes. The arming transition already has its positive control at line 166.

♻️ Proposed additional case
 		{
 			name:        "raising the daily cap while already armed is refused",
 			perms:       autoGateConfigOnly,
 			stored:      armedConfig(),
 			mode:        "auto",
 			enabled:     true,
 			perExchange: 100, daily: 100_000,
 			wantRefused: true,
 		},
+		{
+			name:        "raising a cap while armed with execute:ri-exchange is allowed",
+			perms:       autoGateConfigPlusExecute,
+			stored:      armedConfig(),
+			mode:        "auto",
+			enabled:     true,
+			perExchange: 100_000, daily: 100_000,
+			wantRefused: false,
+		},
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/api/ri_exchange_automode_gate_test.go` around lines 192 - 218, Add a
positive test case to the cap-raise scenarios in the automode gate matrix using
permissions that include execute:ri-exchange, with an armed stored configuration
and a raised per-exchange or daily cap; assert the transition is allowed. Keep
the existing autoGateConfigOnly refusal cases unchanged.
internal/api/handler.go (1)

572-586: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Simplify the tail of the gate.

The final if only forwards the error. Return the call result directly.

♻️ Proposed simplification
-	if _, err := h.requireSessionPermission(ctx, session, auth.ActionExecute, auth.ResourceRIExchange); err != nil {
-		return err
-	}
-	return nil
+	_, err := h.requireSessionPermission(ctx, session, auth.ActionExecute, auth.ResourceRIExchange)
+	return err
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/api/handler.go` around lines 572 - 586, In
requireRIExchangeAutoModeGrant, replace the final error-checking if block with a
direct return of h.requireSessionPermission(ctx, session, auth.ActionExecute,
auth.ResourceRIExchange), preserving the existing early returns and arguments.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@internal/api/handler.go`:
- Around line 572-586: In requireRIExchangeAutoModeGrant, replace the final
error-checking if block with a direct return of h.requireSessionPermission(ctx,
session, auth.ActionExecute, auth.ResourceRIExchange), preserving the existing
early returns and arguments.

In `@internal/api/ri_exchange_automode_gate_test.go`:
- Around line 103-104: Update the comment immediately above armedConfig to refer
to armedConfig as the stored state, replacing the incorrect “armed” subject
while preserving the rest of the explanation.
- Around line 192-218: Add a positive test case to the cap-raise scenarios in
the automode gate matrix using permissions that include execute:ri-exchange,
with an armed stored configuration and a raised per-exchange or daily cap;
assert the transition is allowed. Keep the existing autoGateConfigOnly refusal
cases unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1623f06b-2e17-49db-a14d-098573d40ceb

📥 Commits

Reviewing files that changed from the base of the PR and between 29beea0 and 22de327.

📒 Files selected for processing (4)
  • internal/api/handler.go
  • internal/api/handler_config.go
  • internal/api/handler_ri_exchange.go
  • internal/api/ri_exchange_automode_gate_test.go

@cristim
cristim force-pushed the sec/1765-auto-exchange-write-gate branch from 22de327 to cb7898b Compare August 8, 2026 15:38
@cristim

cristim commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

Independent adversarial review — head cb7898b10

Reviewed in an isolated worktree at the PR head. All findings below were reproduced by executing code, not by reading it. Verdict: not mergeable as-is — F1 is a live bypass of the property this PR exists to establish.


F1 — High, blocking: the gate ignores user-API-key scoping, so a key scoped to update:config can arm unattended exchange

requireRIExchangeAutoModeGrant authorizes via requireSessionPermission(ctx, session, …) (internal/api/handler.go:582), which calls HasPermissionAPI(session.UserID, …) — the owning user's group permissions. It never consults session.UserAPIKeyID.

requirePermissionConstraints, twenty lines above in the same file, does the opposite:

// internal/api/handler.go:506-515
if session.UserAPIKeyID != "" {
    has, err := h.auth.HasAPIKeyPermissionForConstraintsAPI(ctx, session.UserAPIKeyID, session.UserID, …)

That branch exists because of #1758's F2 round, whose comment states the reason exactly: "This prevents a CI key with MaxPurchaseAmount=$100 from spending up to the owning user's full group limit by inheriting the broader group permissions." The new gate reintroduces that class one function later.

Reachable through the real route. PUT /api/ri-exchange/config is AuthUser (internal/api/router.go:316), which admits user API keys. Driven through Router.Route:

  • key's effective permissions: update:config ✅, execute:ri-exchange ❌
  • owning user's group permissions: both
  • control assertion — the key is correctly denied execute:ri-exchange directly (requirePermission runs the key-aware authorizeAPIKey)
  • the same key's arming write (mode:"auto", auto_exchange_enabled:true) through the router: err == nil, and SaveGlobalConfig was called

So the credential that cannot execute one exchange can arm the scheduler to execute all of them. PUT /api/config is AuthAdmin and unreachable to a user API key, so the dedicated route carries this alone — which is precisely why "both handlers are covered" was not sufficient.

Fix direction: make the gate key-aware the way requirePermissionConstraints is, or pass req through and reuse requirePermission, which already runs authorizeAPIKey against the key's effective permissions.


F2 — Medium: the stateless admin API key gets a 500 that blames the store, and the block is inconsistent with that credential's direct execute power

requirePermission short-circuits the admin API key to a synthetic Session{UserID: "admin-api-key"} with no user row (handler.go:247-249, :283-284). The gate then calls HasPermissionAPI("admin-api-key", …) → GetUserPermissions → GetUserByID → "user not found", which requireSessionPermission wraps in a plain fmt.Errorf, not a ClientError.

Observed, driving both routes with the admin API key and an arming body:

route response
PUT /api/config permission check failed: user not found — IsClientError=false (500)
PUT /api/ri-exchange/config failed to save config: permission check failed: user not found — IsClientError=false (500)

The second is literally the outcome the PR body says it removed ("a refusal is returned as its own 403 rather than … blaming the store for an authorization decision"). The IsClientError unwrap in updateRIExchangeConfig doesn't catch it because the error isn't a ClientError in the first place.

Direction is fail-closed, so this is not exploitable — but it blocks nothing either: the same admin API key still reaches execute:ri-exchange directly at handler_ri_exchange.go:1172 and :1713 via the same short-circuit, and requirePermissionConstraints explicitly bypasses the sentinel (handler.go:498-500). Net effect is a documented full-access infrastructure credential losing the ability to save global config whenever the result is armed, with an opaque 500. Please decide the sentinel's treatment explicitly and pin it with a test.


F3 — Low: before := *existing is a shallow snapshot

Correct today (every gated field is a scalar). But GlobalConfig carries GracePeriodDays map[string]int and EnabledProviders []string, and json.Unmarshal into a non-nil slice reuses the backing array. If a future axis of the gate reads a slice or map field — account scoping is the obvious candidate — the "snapshot" silently stops being one. Worth a word in the comment, which currently just says "Snapshot before the wholesale unmarshal".


F4 — Low, test fidelity: the suite drives handlers, not routes

Every case calls h.updateConfig / h.updateRIExchangeConfig directly with a principal holding exactly [update:config]. On the real route, PUT /api/config is AuthAdmin (router.go:110), so that principal never reaches the handler — the production principal there is admin:*. I confirmed the gate is still correct for admin:* on both routes through Router.Route (403, write blocked, both). But the two credential shapes that genuinely diverge from a bearer session — user API key and admin API key — are exactly the two the suite never exercises, and both turned out to carry defects (F1, F2).


What I verified and found correct

Priority 1 — two write paths, plus the search for a third. Enumerated producers, not consumers. Only two production writers of GlobalConfig exist, both via UpdateGlobalConfigAtomic (handler_config.go:95, handler_ri_exchange.go:1965), and both call the helper. SaveGlobalConfig has no production caller outside the store implementation and the test mock. No third writer: migration 000010_ri_exchange_global_config.up.sql defaults to false / 'manual'; the ri_exchange.* keys in internal/config/defaults.go belong to DefaultSettings, which has zero non-test consumers — an inert registry, not a write path. (Method: git grep with [[:space:]] character classes, never \s; each negative sanity-checked against a line known to match — the assignment sweep returned the four real writers plus two == 0 comparisons, proving the pattern was live. Both the string literals "execute", "ri-exchange" and the constants auth.ActionExecute / auth.ResourceRIExchange were searched; the codebase uses both forms and both were found.)

The predicate mirrors the consumer. pkg/exchange/auto.go:248 routes to processManualExchange on params.Config.Mode == "manual" only. RIExchangeEnabled is the master switch at internal/server/handler_ri_exchange.go:42. GlobalConfig.Validate does not constrain the mode. Enabled && Mode != "manual" is right, and the dedicated endpoint's validate() genuinely restricts mode to manual|auto, so limiting _NonManualModeIsArmed to the generic route is correct.

The deliberate hole is justified. processManualExchange generates an approval token and persists a Pending record — no execution. So "enabled while mode stays manual is allowed" really does move no money.

Cap gating is not theatre. Both caps are live-enforced in processAutoExchange (pkg/exchange/auto.go:497-540, chooseEffectiveCap), so refusing a cap raise while armed protects a real bound.

No two-step bypass. enabled=true, mode="manual" then mode="auto" → second write has wasArmed=false → refused. Reverse order likewise. Raising a cap while disarmed is allowed but arming afterwards still requires the verb.

Priority 2 — mutation-verified independently, each mutation applied alone against a committed tree, restored by re-applying the inverse from a pristine snapshot (never git checkout --), worktree confirmed clean before and after every measurement. All six killed:

mutation result killed by
M1 — gate removed from PUT /api/config only KILLED only the generic PUT /api/config subtests + all 5 NonManualModeIsArmed
M2 — gate removed from PUT /api/ri-exchange/config only KILLED only the dedicated subtests
M3 — predicate narrowed to Mode == "auto" KILLED all 5 NonManualModeIsArmed
M4 — cap raise while armed ungated KILLED both cap-raise quadrants, both endpoints
M5 — refuse-everyone KILLED all 4 "still allowed" quadrants
M6 — gate checks update:config KILLED all refusal quadrants + NonManualModeIsArmed

M1 and M2 killing disjoint subtest sets is genuine evidence that the two-route coverage is real rather than incidental. The PR body's mutation table reproduces.

Scope. The PR body's "Out of scope, flagged not fixed" section names the frontend renderAutomationSettings gap and LeanerCloud/cloud-commitments-platform#178 explicitly. It does not imply broader coverage.


Gate results (my runs, at cb7898b10)

gate result
gofmt -l . clean, exit 0
go vet ./... exit 0
gocyclo -over 10 -ignore "_test\.go" . empty, exit 0
go test -race -count=1 ./... exit 0 — 6928 passed, 43 packages
golangci-lint v2.10.1 (CI pin, golangci-lint has version 2.10.1) head: exit 0 and 0 issues.; base eca603a36: exit 0 and 0 issues.
comm set diff, base vs head empty in both directions — zero new, zero removed

Branch is 3 commits behind main (main = eca603a36; PR parent = 726389b48) — worth a rebase before merge, though nothing in the delta touches these files.

Live CI at review time: 20 passed / 0 failed. Review state: only coderabbitai (COMMENTED, 2026-08-08); no human review.

@cristim
cristim force-pushed the sec/1765-auto-exchange-write-gate branch from cb7898b to f833ab0 Compare August 10, 2026 22:59
@cristim

cristim commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

F1–F4 addressed at f833ab067

F1 was a real bypass. I reproduced it before fixing rather than taking it on the write-up, through Router.Route with a user API key whose effective permissions hold update:config but not execute:ri-exchange, owned by a user whose groups hold both:

CONTROL  direct execute:ri-exchange for the key -> permission denied: requires execute on ri-exchange
BYPASS   PUT /api/ri-exchange/config via Router.Route -> err=<nil>  SaveGlobalConfig called=true

Same probe against the fix:

BYPASS   PUT /api/ri-exchange/config via Router.Route -> permission denied: requires execute on ri-exchange  SaveGlobalConfig called=false

F1 — authorize the credential, not the owning user

Took the second option you suggested. The helper now takes req and calls requirePermission, which dispatches through requirePrincipalPermission → authorizeAPIKey for a user API key and evaluates the verb against the key's effective permissions. That reuses the existing correct path rather than adding a second key-aware branch beside requirePermissionConstraints.

Two things fell out of it that are worth noting:

  • F2 fixed by the same change. requirePrincipalPermission short-circuits PrincipalAdminAPIKey, so the stateless admin key now gets the same answer here as on the direct execute handlers instead of a 500 from a group lookup on a sentinel user id. Pinned by TestRIExchangeAutoModeGate_AdminAPIKeyAllowed, which I confirmed fails against the pre-fix shape.
  • The production diff shrank. session is no longer needed in either handler, so both revert to their original if _, err := h.requirePermission(...) form. That also removes the govet shadow workarounds the earlier revision needed — the change is now purely additive.

Your note on why the brief did not catch it is the part I want to record: I verified both write paths and both were genuinely covered, but /api/config is AuthAdmin and unreachable to a user API key, so the dedicated route carries the credential axis alone. Route coverage was necessary and not sufficient; the axis that mattered was credential type. That is now stated in the test file's header comment so the next person does not re-derive it.

F3 — the snapshot is no longer a struct copy

before := *existing is replaced by riExchangeArmState, a four-field value snapshot (enabled, mode, perExchange, daily) built by riExchangeArmStateOf. GlobalConfig carries slices and maps that a shallow copy aliases, so narrowing to exactly the fields the decision reads removes the question rather than answering it.

F4 — the suite drives routes

Every case now goes through Router.Route. This is the finding that actually cost something: the bypass only appears below the router, which is precisely why my own handler-level suite was green while F1 was live.

The principal for the matrix is now admin:* rather than a bare update:config grant, because that is both the #1765 threat model and what /api/config's AuthAdmin gate requires. The mock's decision function runs the real AuthContext.HasPermission, so the carve-out denies execute:ri-exchange to admin:* exactly as production does.

New: TestRIExchangeAutoModeGate_UserAPIKeyUsesKeyPermissions, both directions — the scoped key refused, and a key that legitimately holds execute:ri-exchange still allowed.

Mutation results at the new head

Six mutations, all killed by named tests. M0 is new and is the F1 regression:

mutation killed by
M0 — verb resolved against the owning user instead of the credential only the two UserAPIKeyUsesKeyPermissions cases
M1 — gate removed from PUT /api/config only the generic subtests + all 5 NonManualModeIsArmed
M2 — gate removed from PUT /api/ri-exchange/config only the dedicated subtests + the key test
M3 — predicate narrowed to mode == "auto" all 5 NonManualModeIsArmed
M4 — cap raises while armed no longer gated both cap quadrants, both routes
M5 — gate applied to every config write (refuse-everyone) all 4 "still allowed" quadrants, both routes

M0 killing only the key tests is the useful signal: a session principal resolves identically either way, so the mutation breaks exactly one axis and exactly one test catches it. That is the axis that had no coverage before.

Gates at f833ab067

gate result
gofmt -l . clean (0 files)
go build ./... / go vet ./... exit 0 / exit 0
gocyclo -over 10 -ignore "_test\.go" . exit 0, empty
internal/api + internal/auth + internal/mocks, -race all ok, 0 FAIL
golangci-lint v2.10.1, root module exit 0 and a genuine 0 issues. line

Rebased onto eca603a36 before this work; main has since moved to ea0578cfc, one commit ahead, no conflict.

Nothing touched in the areas the review confirmed correct — the two-writer enumeration, the four quadrants, and the NonManualModeIsArmed predicate coverage are unchanged apart from being re-pointed at routes.

@cristim

cristim commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

Delta review — f833ab067 (was cb7898b10)

Fresh worktree at the new head. F1 is genuinely fixed. One new finding, in the test wiring rather than in production, plus one shipped semantic that deserves to be stated out loud.


F1 — FIXED, verified through Router.Route, both directions

I re-ran the bypass with my own mock wiring rather than the PR's, and deliberately registered no mock.Anything catch-all on HasAPIKeyPermissionAPI, so an unexpected verb query fails loudly instead of being silently granted:

scenario (all via Router.Route, PUT /api/ri-exchange/config) result
key scoped to update:config, owner holds execute:ri-exchange 403, message names execute on ri-exchange, SaveGlobalConfig not called
key that legitimately holds execute:ri-exchange allowed, write lands
scoped key writing mode:"manual" allowed, write lands

The middle row is the one that matters for not breaking real CI credentials, and the third confirms the configure-but-do-not-execute role survives on the key axis too. I asserted the refusal's provenance (the 403 text names the verb and resource) so it is provably the gate refusing rather than some other check on the path.

Mutation. Reverting the gate to the pre-fix authorization — resolve the session from the request, then requireSessionPermission on the session's user — makes TestRIExchangeAutoModeGate_UserAPIKeyUsesKeyPermissions/key_without_execute:ri-exchange_is_refused fail. The coverage is real. Control mutation (gate authorizes update:config through requirePermission instead) is killed cleanly by all three refusal quadrants on both routes plus the API-key test.

Routing requirePermission rather than adding a second key-aware branch was the right call. Router.Route establishes a principal before dispatch (router.go:394-416; validateSecurityContext at handler.go:766-770 on the real request path), so inside the gate requirePermission reads the principal and does not re-validate the credential from headers — no second ValidateSession inside the advisory-locked transaction.


F5 — NEW: the key test's decision function has the wrong signature, so it kills by panic instead of by its own assertion

autoGateKeyRouter registers the owning user's permissions as a 4-argument function:

mockAuth.On("HasPermissionAPI", …).
    Return(func(_ context.Context, _, action, resource string) bool { … }, nil).Maybe()

permissionDecision (internal/api/mocks_test.go:289) type-asserts to a 2-argument func(action, resource string) bool. The assertion fails, control falls through to args.Bool(0), and that panics:

panic: assert: arguments: Bool(0) failed because object wasn't correct type

Two consequences:

  1. The premise the test documents is never actually modeled. The comment says "The owning user's groups are BROADER than the key… A gate that resolved the verb against the USER would pass here" — but the owner's decision function can never be consulted, so that scenario is asserted rather than exercised.
  2. The regression kills by panic, not by assertion. A panic aborts the whole test binary, so a future regression here takes the rest of internal/api's results down with it and reports as a stack trace rather than as "arming was not refused".

Proof it is exactly the signature: change that one function to func(action, resource string) bool and re-run the same mutation — it then fails cleanly at ri_exchange_automode_gate_test.go:336 with require.Error / "An error is expected but got nil", which is precisely the intended assertion and precisely the F1 bug. One-line fix; worth taking before merge.

On the #1744 shadowing class specifically: the ordering here is correct. The two specific HasAPIKeyPermissionAPI registrations precede the mock.Anything catch-all, and testify's findExpectedCall returns the first matching registration with Repeatability > -1, so the specific ones serve the calls. I confirmed independently by re-running the same scenarios with no catch-all at all — identical results. The catch-all still earns deletion (it can only mask a change in which verbs the path queries, and it returns true), but it is not shadowing anything today.


F2 — resolved, and the resulting semantic should be said plainly

The stateless admin API key now succeeds on both routes: err=<nil>, write lands. The failed to save config: … user not found 500 is gone. Inside the gate, requirePermission reads PrincipalAdminAPIKey and returns the session with no permission check at all — consistent with the same key on the direct execute handlers (handler_ri_exchange.go:1172, :1713) and with requirePermissionConstraints' explicit sentinel bypass, exactly as the new comment claims.

So the semantic that ships is: the stateless admin API key can arm unattended RI exchange with no execute:ri-exchange check. That is defensible — it is an infrastructure credential, not a user account, and #1758's carve-out is about compromised admin accounts — but nothing pins it. If someone later hardens requirePrincipalPermission, no test catches the change on a money path. Two lines would fix that.


F3 — fixed, better than what was asked

riExchangeArmState is an explicit value snapshot of exactly the four compared scalars. That removes the aliasing question structurally instead of commenting around it, and makes the gate's signature say what it reads.

F4 — fixed

Every case now runs through Router.Route, and the generic route's principal is admin:* — the principal that can actually reach an AuthAdmin route, and the #1765 threat model. Small residual: with the matrix on admin:*, a plain non-admin session holding only update:config on the dedicated AuthUser route is no longer covered by any quadrant. The API-key tests do cover a non-admin credential, so the shape is not wholly untested. Low.


Re-verified at this head (no regression from the delta)

  • The predicate still mirrors the consumer: riExchangeArmState.armed() is enabled && mode != "manual", matching pkg/exchange/auto.go's params.Config.Mode == "manual" routing.
  • Both spend caps are still live-enforced in processAutoExchange / chooseEffectiveCap, so gating cap raises still protects a real bound.
  • Still exactly two production writers of GlobalConfig, both routed through the gate.
  • All four quadrants intact on both routes.

Gates at f833ab067

gate result
gofmt -l . clean, exit 0
go vet ./... exit 0
gocyclo -over 10 -ignore "_test\.go" . empty, exit 0
go test -race -count=1 ./... exit 0 — 6931 passed, 43 packages
golangci-lint v2.10.1 head exit 0 and 0 issues.; merge-base eca603a36 exit 0 and 0 issues.
comm set diff vs merge-base empty in both directions
live CI 20 passed / 0 failed

Two housekeeping notes: the branch is 1 behind main (ea0578cfc), so a rebase is needed — trivial, nothing in #1795 touches these files. And the CodeRabbit check reports pass with the reason "Review rate limited", which is not evidence that a review ran.

execute:ri-exchange is carved out of admin:* (#1644, PR #1758) so a
compromised admin account alone cannot drain commitments. update:config is
deliberately NOT carved out -- but a single update:config write could set
ri_exchange_mode "auto" plus ri_exchange_enabled, after which the scheduled
TaskRIExchangeReshape -> RunAutoExchange -> processAutoExchange executed
exchanges against the provider with no execute:ri-exchange check anywhere on
that path. The carve-out's guarantee did not hold via that route.

Arming auto mode is functionally equivalent to pre-authorizing every future
exchange, so the transition into that state now requires the caller to also
hold execute:ri-exchange. The verb is reused, not reinvented.

Authorization goes through requirePermission with the request the caller was
authenticated from, so the verb is evaluated against THE CREDENTIAL PRESENTED
rather than the owning user's group permissions. For a user API key that
routes to authorizeAPIKey and checks the KEY's effective permissions.
Resolving it against the session's user id instead let a CI key that is
correctly denied execute:ri-exchange arm the scheduler to execute every
exchange -- the same permission-inheritance bug requirePermissionConstraints
already guards against one function above, reintroduced one function later.
It also gives the stateless admin API key the same answer here as on the
direct execute handlers, rather than failing a group lookup on a sentinel user
id and surfacing a 500 that blamed the store for an authorization outcome.

Both write paths are gated through one helper. PUT /api/config unmarshals the
body onto the whole GlobalConfig, so it reaches ri_exchange_mode and
ri_exchange_enabled exactly as effectively as the dedicated
PUT /api/ri-exchange/config. The two differ in reach, though: /api/config is
AuthAdmin and unreachable to a user API key, so the dedicated route carries
the credential axis alone -- covering both handlers was necessary and not
sufficient.

"Armed" is defined as enabled AND mode != "manual", mirroring the consumer
rather than the field's nominal domain. processRecommendation routes to
processManualExchange only on the literal "manual" and sends every other value
to processAutoExchange, and GlobalConfig.Validate never constrains
RIExchangeMode -- so via PUT /api/config a mode of "Auto", "automatic" or ""
arms unattended execution just as effectively as "auto". A gate written as
mode == "auto" would have been open on precisely the route that matters.

The compared state is a four-field value snapshot rather than a copy of the
whole struct: GlobalConfig carries slices and maps that a shallow copy would
alias, so narrowing to the fields the decision reads removes the question.

Only the escalation is gated. An update:config-only operator keeps view:config,
mode "manual", the utilization and lookback tunables, and ri_exchange_enabled
while mode stays manual -- that only has the scheduler raise Pending approvals,
which move no money. Raising a spend cap while already armed is also gated,
since the caps are plain fields on the same struct and the same actor would
otherwise set both the switch and its bound. Lowering a cap, disarming, and an
idempotent re-save of an already-armed config are de-escalations and stay
available.

The check runs inside the UpdateGlobalConfigAtomic closure so it compares
against the same advisory-locked row the write lands on; checking beforehand
would race a concurrent writer between the check and the write.

Tests drive Router.Route rather than the handlers: the credential axis is
invisible below the router, which is why a handler-level suite passed while
the key bypass was live.

Refs #1765
@cristim
cristim force-pushed the sec/1765-auto-exchange-write-gate branch from f833ab0 to 7c398af Compare August 10, 2026 23:29
@cristim

cristim commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

F5 fixed at 7c398aff4

You were right, and the distinction matters more than the one-line fix suggests. permissionDecision (mocks_test.go:288) type-asserts exactly func(action, resource string) bool; mine was func(context.Context, string, string, string) bool, so the assertion failed, execution fell through to args.Bool(0), and that panicked on a func value. Red either way — but by panic, not by the assertion that was supposed to be doing the work.

Signature corrected, and the reason recorded at the call site so the next person does not re-derive it.

Verified against the faithful pre-fix shape — resolve the session from the credential correctly, then check the verb against session.UserID:

ri_exchange_automode_gate_test.go:341:
    Error:  An error is expected but got nil.
--- FAIL: TestRIExchangeAutoModeGate_UserAPIKeyUsesKeyPermissions/key_without_execute:ri-exchange_is_refused

No panic. The kill is require.Error on the refusal case, and the allow case correctly still passes — which is what makes it a discriminating test rather than a blunt one.

Worth noting for the record: my first attempt at this mutation used requireSessionPrincipal, which fails for an API-key principal because there is no session at all. That went red too, for a reason unrelated to F1. A mutation that breaks more than the property under test proves nothing about the test, so the result above rests on the shape that reproduces the actual bug and nothing else.

Catch-all removed

Took the option. It returned true unconditionally, which is the same hazard class as F5 — a permissive stub that can only ever hide something. Removed, and re-verified both directions: the suite passes, and the mutation still produces the clean require.Error kill with no catch-all present. Your independent check and mine agree.

F4 residual recorded

Added to the PR body rather than expanded into the matrix, as directed: a plain non-admin session holding only update:config on the dedicated AuthUser route is not covered by any quadrant, though the API-key tests do exercise a non-admin credential on that route.

Gates at 7c398aff4

Rebased onto ea0578cfc first, so this is measured on current main.

gate result
gofmt -l . clean (0 files)
go build ./... / go vet ./... exit 0 / exit 0
gocyclo -over 10 -ignore "_test\.go" . exit 0, empty
internal/api + internal/auth + internal/mocks, -race all ok, 0 FAIL
golangci-lint v2.10.1, root module exit 0 and a genuine 0 issues. line

Force-pushed with --force-with-lease pinned to f833ab06775e48fe3c758a8920f3eeb8d72fef80.

Nothing else touched — the two-writer enumeration, the four quadrants, NonManualModeIsArmed, and the F1/F2/F3 fixes are unchanged.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
internal/api/ri_exchange_automode_gate_test.go (1)

326-358: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add stateless admin API-key route coverage.

The test matrix covers bearer sessions and user API keys only. It does not cover the documented stateless admin API-key bypass. Add allowed arming cases for dedicatedPath and genericPath. This prevents a synthetic-user lookup failure or an incorrect denial from returning without regression coverage.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/api/ri_exchange_automode_gate_test.go` around lines 326 - 358,
Extend TestRIExchangeAutoModeGate_UserAPIKeyUsesKeyPermissions with stateless
admin API-key cases that arm auto mode successfully through both dedicatedPath
and genericPath. Configure each request with the documented stateless admin key,
assert no routing error, and verify the store write occurs, covering both route
variants without relying on synthetic-user lookup.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@internal/api/ri_exchange_automode_gate_test.go`:
- Around line 326-358: Extend
TestRIExchangeAutoModeGate_UserAPIKeyUsesKeyPermissions with stateless admin
API-key cases that arm auto mode successfully through both dedicatedPath and
genericPath. Configure each request with the documented stateless admin key,
assert no routing error, and verify the store write occurs, covering both route
variants without relying on synthetic-user lookup.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ec58eb1a-f345-4373-a8fe-bf8781476dec

📥 Commits

Reviewing files that changed from the base of the PR and between 22de327 and 7c398af.

📒 Files selected for processing (4)
  • internal/api/handler.go
  • internal/api/handler_config.go
  • internal/api/handler_ri_exchange.go
  • internal/api/ri_exchange_automode_gate_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/api/handler_ri_exchange.go
  • internal/api/handler.go

@cristim

cristim commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

Merging. Closes #1765.

Merging on independent adversarial review — CodeRabbit produced no verdict at any head here.

The defect

admin:* retains update:config by design. One update:config write can set mode:"auto" + auto_exchange_enabled:true + both spend caps. The scheduled TaskRIExchangeReshape -> RunAutoExchange -> processRecommendation then branches on Config.Mode; anything other than "manual" reaches processAutoExchange, which executes against the provider with no execute:ri-exchange check anywhere on that path. So #1758s carve-out guarantee, "a compromised admin account alone cannot drain commitments", failed through this route.

Review found a live bypass in the fix, and it is the sharpest finding of this series

The first version authorized via requireSessionPermission, which calls HasPermissionAPI(session.UserID, ...) — the owning users group permissions — and never consulted session.UserAPIKeyID. Driven through Router.Route with a real user API key:

  • key holds update:config, not execute:ri-exchange
  • control: the key is correctly refused execute:ri-exchange directly
  • the same keys arming write: err == nil, SaveGlobalConfig called

A CI key that cannot execute a single exchange could arm the scheduler to execute all of them. It reintroduced the exact class #1758s F2 round fixed, twenty lines from the comment explaining why: "This prevents a CI key with MaxPurchaseAmount=$100 from spending up to the owning users full group limit."

Two things made it invisible. The authors suite drove handlers rather than routes, and the bypass only appears through Router.Route. And the orchestration brief asked for both write paths to be verified — which they were, correctly. But PUT /api/config is AuthAdmin and unreachable to a user API key, so the dedicated route carried this alone. The axis that mattered was credential type, not route.

Fixed by routing through requirePermission, which already runs authorizeAPIKey. That also avoids a second ValidateSession inside the advisory-locked transaction, which the alternative key-aware branch would have introduced.

Then the verifying test was itself killing by panic

F5: the decision function wired into the key test had the wrong signature, so under mutation it died via a testify panic rather than via require.Error. It still went red, which is exactly why it looked fine. Corrected to func(action, resource string) bool; the same mutation now fails cleanly at the intended assertion.

Three layers, each visible only once the one above it was fixed.

Verified, by execution

  • F1 fixed, confirmed through Router.Route both directions; reverting to the pre-fix authorization makes the API-key refusal test fail, so the coverage is real.
  • The enumeration is complete. Producers, not consumers: only two production writers of GlobalConfig exist, both via UpdateGlobalConfigAtomic, both calling the helper. SaveGlobalConfig has no production caller outside the store and the test mock. No third writer — migration 000010 defaults to false/manual, and the ri_exchange.* keys in defaults.go belong to DefaultSettings, which has zero non-test consumers.
  • Method was right: [[:space:]] never \s, every negative sanity-checked against a line known to match, and both the string literals and the auth.ActionExecute/auth.ResourceRIExchange constants searched, since the codebase uses both forms.
  • All four original mutations killed, including both cap-raise quadrants at both endpoints.
  • The fix(test): make grantAdmin model the principal instead of stubbing the authorization decision #1744 expectation-shadowing class does not apply — the specific HasAPIKeyPermissionAPI registrations precede the catch-all, confirmed independently by re-running with no catch-all at all and getting identical results.

Scope, stated rather than implied

Gates the transition, not the surface. An update:config-only operator keeps view:config, mode:"manual", threshold tuning, and RIExchangeEnabled=true while mode stays manual — harmless, since the scheduler then only creates Pending approvals. Raising a cap while already armed is gated too, which is the subtle half: the caps are real live-checked enforcement, but the same actor sets switch and ceiling in one request.

The frontend gap (renderAutomationSettings, zero canAccess gating) and #1768 (the inert ladder equivalent) remain out of scope.

One residual, recorded rather than fixed: with the F4 matrix on admin:*, a plain non-admin session holding only update:config on the dedicated AuthUser route is not covered by any quadrant. The API-key tests cover a non-admin credential, so the shape is not wholly untested.

Gates at 7c398aff4: 20/20 checks, zero unresolved threads, rebased onto ea0578cfc.

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

Labels

effort/m Days impact/many Affects most users priority/p1 Next up; this sprint severity/high Significant harm triaged Item has been triaged type/security Security finding urgency/this-sprint Within the current sprint

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sec(exchange): scheduled auto-exchange bypasses the execute:ri-exchange carve-out via update:config

1 participant