sec(api): scope ladder config writes to the caller's allowed_accounts - #1770
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughThe ladder configuration upsert path now checks the caller’s account scope and provider before writing. Inaccessible and nonexistent accounts return the shared not-found error. Tests cover scoped, in-scope, and unrestricted sessions. ChangesLadder configuration authorization
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related issues
Possibly related PRs
Sequence Diagram(s)sequenceDiagram
participant Client
participant upsertLadderConfig
participant requireLadderAccountAccess
participant LadderConfigStore
Client->>upsertLadderConfig: Submit ladder configuration
upsertLadderConfig->>requireLadderAccountAccess: Check session account access
requireLadderAccountAccess-->>upsertLadderConfig: Return access result
alt Account is accessible
upsertLadderConfig->>LadderConfigStore: Upsert configuration
else Account is inaccessible or nonexistent
upsertLadderConfig-->>Client: Return errNotFound
end
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
Independent review at
|
| side | result |
|---|---|
base 6df07bb7f |
exit 0, genuine 0 issues. |
| head | exit 1, 4 issues: govet: 4 |
All four are new, all in the changed file, nothing removed:
internal/api/handler_ladder.go:100:5: shadow: declaration of "err" shadows declaration at line 89 (govet)
internal/api/handler_ladder.go:115:5: shadow: declaration of "err" shadows declaration at line 89 (govet)
internal/api/handler_ladder.go:120:5: shadow: declaration of "err" shadows declaration at line 89 (govet)
internal/api/handler_ladder.go:124:5: shadow: declaration of "err" shadows declaration at line 89 (govet)
Cause is the one-line change that captures the session:
session, err := h.requirePermission(ctx, req, "update", "config") // line 89 — err now function-scopederr was previously confined to if _, err := .... Hoisting it means the four pre-existing if err := ... statements below (dec.Decode, json.Unmarshal, cfg.Validate, requireLadderAccountAccess) now shadow it.
Confirmed live, not just local: gh pr checks 1770 shows Lint Code fail (4m59s) and the aggregate CI Success fail. This is separate from the inherited internal/auth test failure — that one is in Unit Tests, still pending on this run.
Smallest fix that keeps the diff shape:
session, permErr := h.requirePermission(ctx, req, "update", "config")
if permErr != nil {
return nil, permErr
}Verified clean
The guard refuses at the routed endpoint, not just in the helper. Drove NewRouter(h), located the registered PUT /api/ladder/configs route rather than assuming it exists, and called its handler:
ROUTED out-of-scope account auth=AuthUser err=not found store write reached = false
ROUTED in-scope account auth=AuthUser err=<nil> store write reached = true
There is exactly one route reaching this write (router.go:326 → upsertLadderConfigHandler → h.upsertLadderConfig, a bare pass-through), so the tests calling the handler method are not bypassing a gate.
The refusal is produced by this guard and nothing else. Mutated away only the allowed_accounts filtering, keeping the existence lookup and the nil→errNotFound handling intact:
TestUpsertLadderConfig_OutOfScopeAccountRefusedbecomes the sole failure in the package.- The routed probe then shows
store write reached = truefor the out-of-scope account — the pre-fix vulnerability reproduced end to end at the route. - The in-scope case is unaffected by the mutation, so that test is not accidentally coupled to the guard.
(Method note: my first mutation replaced requireAccountAccess with a bare GetCloudAccount, which dropped the nil check too and nil-dereferenced — the panic aborted the binary before the scoping tests ran. A mutation that removes more than the property under test proves nothing; worth stating because the corrected one is what the result above rests on.)
Both directions are genuinely covered. _OutOfScopeAccountRefused / _InScopeAccountStillAllowed, plus _UnrestrictedSessionUnaffected as a control that the refusal tracks the caller's scope rather than the account. The in-scope test fails against a refuse-everyone handler, which is the property a refusal-only suite lacks.
The wildcard predicate is right, because the PR did not write one. It reuses the shared requireAccountAccess, which delegates to auth.IsUnrestrictedAccess — that handles len(allowed) == 0 and a "*" entry, then auth.MatchesAccount. No bespoke comparison was introduced, which is exactly how the #1752 predicate error is avoided. The _UnrestrictedSessionUnaffected test pins it.
The reachability claim that moved severity checks out. Verified with validated negatives (each negative grep sanity-checked against a pattern known to match):
- No migration seeds
update:config. The JSON-form negative returns nothing, while the same pattern shape matches{"action":"view","resource":"config"}at000088:20,26— so the negative is real, not a silent no-match. - No default permission set contains
{Action: ActionUpdate, Resource: ResourceConfig}in production code. The only three hits are test fixtures (service_apikeys_test.go:962,1180,service_group_test.go:416); theActionViewform matches attypes.go:611,623as sanity. - Migration 000088 says so in its own comment: "Non-admin groups are NOT granted update:config here."
So it needs an operator-created custom group granting update:config and carrying a restricted allowed_accounts. Supported and legitimate, but not a default. severity/high is correct.
The enumeration is complete. Rather than grepping for the pattern, I derived the 36 mutating store methods from internal/config/interfaces.go, then parsed every func (h *Handler) body in internal/api for one that both writes and takes an account identifier — 10 candidates, resolved individually:
| handler | verdict |
|---|---|
upsertLadderConfig |
fixed here (my per-function heuristic flagged it as unscoped only because the check lives in requireLadderAccountAccess) |
setPlanAccounts |
genuinely unscoped — PUT /api/plans/{id}/accounts, Auth: AuthUser, update:plans, then SetPlanAccounts(ctx, id, body.AccountIDs) with no scoping on any supplied ID. Confirms #1769. |
persistDiscoveredMembers, approveRegistration |
AuthAdmin routes — outside the scoped-caller class |
persistRetryExecution, persistExecutionAndSuppressions |
account id derived from the execution/recs, not caller-supplied |
revokeScheduledExecution |
gated by authorizeSessionRevokeExecution |
saveAccountServiceOverride, deleteAccountServiceOverride, marketplaceCancel |
already scoped |
So the class has exactly two members: this one and #1769. Ladder-specific: only two ladder store calls exist in the API — GetLadderConfigs (already filtered) and UpsertLadderConfig (now guarded); there is no delete endpoint. No unguarded sibling remains.
The name-only AssertCalled(t, "UpsertLadderConfig") calls are legitimate. MockConfigStore shadows the helpers with callLog-backed versions (internal/mocks/assertions.go:134,154) where name-only deliberately means "called at all", and #1750's TestNoUnfailableMockAssertions passes on the rebased head — so these are not the unfailable form.
Two observations — neither blocks this PR
#1769 is more reachable than #1539, which may warrant re-triage. Plan Authors (00000000-...-000000000003) is a seeded group holding update:plans (migration 000024). Its seeded allowed_accounts is ['*'], so out of the box it is unrestricted and the gap is moot — but narrowing an existing seeded group's allowed_accounts is a one-field operator change, whereas #1539 requires creating a custom group and granting a verb no seeded group carries. Worth reflecting in #1769's labels.
handler_purchases_revoke.go:398 has the wildcard-predicate error in the over-refusal direction (pre-existing, unrelated to this diff):
if len(allowed) > 0 && !stringInSlice(*record.CloudAccountID, allowed) {stringInSlice is exact-match and ResolveAllowedAccounts returns AllowedAccounts verbatim, so a scope of ["*"] satisfies len > 0 and fails the membership test — a 403 on every revoke-own. Latent rather than live: admins short-circuit on revoke-any at :361, and no migration seeds revoke-own (validated — no "action":"revoke grant appears in any migration, and DefaultUserPermissions() is consumed only by cmd/gen-permissions). It needs an operator-created group granting revoke-own with ['*'], which is the shape copying a seeded group would produce. Denial of a feature, not a security hole. Worth its own issue.
Gates at c57b40081
| 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 ./... at PR head |
31 ok, 1 FAIL — TestGrantCeiling_ConstraintContainment, the known inherited failure (branch predates #1772) |
same, rebased onto 6df07bb7f — internal/auth, internal/api, internal/mocks |
all ok, so the inherited failure is the only test failure and it is not this PR's |
golangci-lint v2.10.1, root module, comm set diff |
4 new, 0 removed — F1 |
Verdict: not mergeable as-is on F1 alone. The security change itself is correct, well-tested in both directions, verified at the routed endpoint, and the enumeration is complete — fix the shadowing and this is ready.
upsertLadderConfig gated only on update:config and took cloud_account_id straight from the request body, so a caller scoped to one account could write the commitment-laddering config of any other. The read side of the same feature has filtered on allowed_accounts since migration 000088 opened view:config to scoped users; the write side never did, so the row was also filtered back out of the caller's own GET afterwards. Reachable by any principal holding update:config with a restricted allowed_accounts. No seeded group grants that pairing -- migration 000088 deliberately withheld update:config from the non-admin groups -- so it needs an operator-created custom group, which is why this is severity/high rather than critical. A plain admin is unaffected either way: admin:* carries allowed_accounts ARRAY['*'], so the new check passes for it. What it buys, concretely: the ladder engine only executes configs whose account matches the deployment's own AWS account (the single-account gate in internal/server/handler_ladder.go), and for that account mode auto_approve with a nil max_hourly_commit_per_run means uncapped auto-purchase. Because the store upserts on UNIQUE(cloud_account_id, provider), an out-of-scope caller could also overwrite an admin's existing row for that account -- raise the cap, flip the mode, or set enabled:false to silently stop a running ladder. validateLadderAccountProvider becomes requireLadderAccountAccess and delegates the lookup to requireAccountAccess, which already fetches and nil-checks the account, so the account is fetched once rather than twice. The rename is deliberate: the function now enforces authorization, not just validation. Behaviour change: a cloud_account_id that does not resolve now returns errNotFound (404) instead of 400 "cloud account %q does not exist". requireAccountAccess answers "no such account" and "not yours" identically on purpose -- a distinguishable 400 would let a scoped caller enumerate the account table by probing ids. Deliberately NOT added: the requirePermissionConstraints call the issue's fix direction suggests. requirePermissionConstraintsAction is hardcoded to "execute" (handler.go), with a comment saying a genuinely new action should add a real parameter back rather than resurrect an unused one. Calling it here would evaluate the caller's execute permission on config, which is not the verb being exercised and would fail closed for anyone holding only update:config. The allowed_accounts check is what closes the reported bypass. Tests cover both directions -- out-of-scope refused, in-scope still allowed -- plus an unrestricted session writing the same account the scoped one was refused, which pins that the refusal is driven by the caller's scope and not by anything about the account. Closes #1539
Capturing the session (session, err := h.requirePermission) introduced a function-scoped err, which the four subsequent `if err := ...` statements in the same function then shadowed. govet's shadow check flags all four, failing the Lint job. The findings are a side effect of this branch: the previous `if _, err := ...` bound err inside the if-statement, so no function-scoped err existed to shadow. Bind the permission error as permErr instead of err. That leaves the four body-parsing and validation statements untouched -- they are unrelated to this change and rewriting them to `err = ...` would enlarge the diff for no behavioural gain -- and matches the naming already used elsewhere in the package for exactly this situation (planErr, apiKeyErr, awsIDErr). golangci-lint v2.10.1 (the CI-pinned version) now exits 0 with a genuine "0 issues." line. Verified by re-running the linter, not inferred: the earlier run in this worktree reported exit 3 with no findings and the message "parallel golangci-lint is running", which is lock contention rather than a clean result and must not be read as one. No behaviour change: the rename does not touch the guard, and the ten ladder handler tests still pass.
c57b400 to
6064849
Compare
F1 fixed — head
|
| gate | exit | verdict |
|---|---|---|
| golangci-lint v2.10.1 (CI-pinned) | 0 | 0 issues. line present — genuinely clean |
go build ./internal/api/ |
0 | pass |
go test ./internal/api/ |
0 | ok, 34.9s |
go test ./internal/auth/ |
0 | ok, 11.1s — the inherited failure is gone |
| ladder handler tests | 0 | 10/10 |
Guard re-verified after the rebase, not carried forward
A rebase plus an edit inside the same function is where a previously-verified guard can quietly stop being load-bearing, so the mutations were re-run on the new head rather than assumed to still hold. Each test run alone, after committing:
| mutation | expected | observed |
|---|---|---|
M1 scope check → plain GetCloudAccount (the fix removed) |
out-of-scope test fails | FAIL — in-scope and unrestricted still pass, so M1 stays precisely targeted |
| M2 refuse everyone | both positive controls fail | both FAIL — they are still not vacuous |
git diff clean after each inverse-edit undo.
Thanks for the enumeration detail on the review — deriving the 36 mutating store methods from interfaces.go and resolving all 10 candidates individually is a stronger result than my grep-based sweep, and knowing the class has exactly two members bounds #1769 usefully. Picking up #1769 next.
|
Merging. Closes #1539. Merging on independent adversarial review — CodeRabbit has no verdict at this head.
Re-triaged The enumeration is worth recording for how it was done. Rather than grepping for the pattern, the reviewer derived the 36 mutating store methods from Result: the class has exactly two members, this one and #1769. Only two ladder store calls exist in the API — A grep tells you what matched a pattern; deriving the candidate set from the interface and resolving each supports the stronger claim that the class is closed. That also makes #1769 the last member rather than the start of an open-ended sweep. This is the recurring failure shape in this repo — a guard on the primary writer defeated by an unguarded sibling writing the same field. It is why #1737s first enumeration missed Verified in review: the guard refuses at the routed endpoint, not merely in the scoping helper — the distinction that let #1757 ship a test asserting "CSRF MUST be required" which passed while never touching the enforced path. Both directions covered: out-of-scope refused, in-scope still allowed. One blocking finding, now fixed: the first revision introduced 4 new Gates at Sibling still open: #1769 ( |
Closes #1539
Established before fixing
The issue reads as a plausible-but-unverified claim, so I checked it by execution rather than by reading. It is real, and two of its stated premises are narrower than written — both recorded on the issue.
Drove the real routed handler (
PUT /api/ladder/configs→upsertLadderConfigHandler→upsertLadderConfig,router.go:326,Auth: AuthUser) with a principal scoped to one account, targeting a different one:Companion probe, same principal, same two rows, read side:
What was actually wrong
upsertLadderConfigdiscarded the session (if _, err := h.requirePermission(...)) and nothing between the decode andh.config.UpsertLadderConfigconsultedallowed_accounts.validateLadderAccountProviderchecked existence and provider match only.getLadderConfigshas filtered onallowed_accountssince migration 000088 openedview:configto scoped users — its doc comment records exactly that reasoning. It was applied to the read and not to the write, so the row was also filtered back out of the writer's own GET afterwards.Severity, stated honestly
Filed as
severity/critical; re-triaged toseverity/highduring investigation,p1/urgency/nowunchanged. Two independent narrowings:No seeded group grants
update:configto a scoped user. Migration 000088 says so in its own header — "Non-admin groups are NOT grantedupdate:confighere."ResourceConfigappears in the defaults only as{ActionView, ResourceConfig}. So this needs an operator-created custom group grantingupdate:configand carrying a restrictedallowed_accounts. A plain admin is unaffected either way:admin:*carriesallowed_accounts ARRAY['*'], so the new check passes for it. There is no privilege escalation for the principals that hold the verb today.The auto-purchase consequence needs the target to be the deployment's own AWS account. The issue states the engine "auto-purchases uncapped commitments on account B". That holds only when B is the account the deployment runs in —
processOneLadderConfig(internal/server/handler_ladder.go:234) skips any config whoseExternalID != ownAccountIDasmulti_account_unsupported, and execution also needsladdering_enabledandladder_execution_enabledglobally.mode:auto_approveand a nilmax_hourly_commit_per_runboth reach the engine vialadderConfigToEngine. And because the store upserts onUNIQUE(cloud_account_id, provider), an out-of-scope caller can overwrite an admin's existing row: raise the cap, flip the mode, or setenabled:falseto silently stop a running ladder.Same-tenant wrong-scope, not cross-tenant.
high, notcritical— but not lower either, because once laddering is in use at all both global flags are on by definition, so the tamper case is cheap.What landed
validateLadderAccountProviderbecomesrequireLadderAccountAccessand delegates the lookup torequireAccountAccess, which already fetches and nil-checks the account — so the account is fetched once rather than twice. The rename is deliberate: the function now enforces authorization, not just validation, and a reviewer seeingvalidate…would not expect an authz check inside it.Ordering inside the helper matters and is not incidental:
requireAccountAccessruns before the provider comparison, so the400 "provider %q does not match cloud account provider %q"— which names the account's provider — is unreachable for an out-of-scope caller.Behaviour change. A
cloud_account_idthat does not resolve now returnserrNotFound(404) instead of400 "cloud account %q does not exist".requireAccountAccessanswers "no such account" and "not yours" identically on purpose: a distinguishable 400 would let a scoped caller enumerate the account table by probing ids. One existing test asserted the 400 and was updated with that reasoning recorded on it.Deliberately NOT added: the
requirePermissionConstraintscall the issue's fix direction suggests.requirePermissionConstraintsActionis hardcoded to"execute"(handler.go:472), with a comment saying a genuinely new action should add a real parameter back rather than resurrect an unused one. Calling it here would evaluate the caller's execute permission onconfig— not the verb being exercised — and would fail closed for anyone holding onlyupdate:config. Theallowed_accountscheck is what closes the reported bypass; adding a second, mis-aimed authorization layer would be its own bug surface.Verification
Coverage runs both directions, since a refusal-only test passes equally well against a handler that refuses everyone. Mutation-verified per test, each run alone, on the committed diff:
GetCloudAccount(the fix removed)OutOfScopeAccountRefusedfailsProviderMismatchRejectedfailsTestUpsertLadderConfig_UnrestrictedSessionUnaffectedwrites the same account the scoped principal was refused, which pins that the refusal is driven by the caller's scope rather than by anything about the account.Gates at this head, as actually observed at the time of opening:
go build ./...0 ·go vet ./...0 ·go vet -tags=integration ./...0 ·internal/apigreen (29.9s). The full./internal/... ./cmd/...run, golangci-lint v2.10.1 (the CI-pinned version) andgocyclo -over 10 -ignore "_test\.go"were still running locally when this was opened — I will post the exit codes as a follow-up comment rather than assert them here. CI covers the same ground independently.Known-red
main:TestGrantCeiling_ConstraintContainmentininternal/authfails 3 subtests onmainitself — #1737's test usesexecute:ri-exchangeas a generic fixture verb and #1758 then added that verb toadminCarvedOuts, socheckGrantCeilingrefuses it before the containment logic under test is reached. This PR's diff touches 0 files underinternal/auth, so that failure cannot originate here; it clears when the test-only fix lands.Sibling found and filed separately
The completeness sweep over all 321
*Handlermethods (attributing 74/74requirePermissioncall sites — a first pass matched only the two-arg signature and silently covered 13 of 74) found one more real instance of this shape: #1769,setPlanAccountsathandler_accounts.go:1304, which writes plan→account associations with neitherrequirePlanAccessnorrequireAccountAccess. It is higher-reachability than this one —update:plansis a default Standard User grant — and is filed separately because it needs a different file and can land independently.validateExecutePurchaseRequestalso surfaced as a suspect and is a false positive: it callsvalidatePurchaseRecommendationScope.Summary by CodeRabbit
Bug Fixes
Tests