Skip to content

Commit 29beea0

Browse files
authored
sec(api): scope ladder config writes to the caller's allowed_accounts (#1770)
* sec(api): scope ladder config writes to the caller's allowed_accounts 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 * fix(api): avoid shadowing err in upsertLadderConfig 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.
1 parent 88f16ec commit 29beea0

2 files changed

Lines changed: 135 additions & 19 deletions

File tree

‎internal/api/handler_ladder.go‎

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,24 @@ func (h *Handler) filterLadderConfigsByAllowedAccounts(ctx context.Context, sess
7373
// the auth component is nil (returns an error, never a session), so the
7474
// handler fails closed; the exact status is a 500-class error in that case
7575
// rather than a 403, but no unauthenticated write ever reaches the store.
76+
//
77+
// update:config alone is NOT sufficient: the target account must also be
78+
// inside the session's allowed_accounts (issue #1539). Holding the verb said
79+
// nothing about WHICH account it could be exercised on, so a caller scoped to
80+
// one account could write the ladder config of any other -- including the
81+
// deployment's own account, whose config the ladder engine acts on, and whose
82+
// existing row this endpoint overwrites (the store upserts on the
83+
// UNIQUE(cloud_account_id, provider) pair). getLadderConfigs has filtered on
84+
// allowed_accounts since migration 000088 opened it to scoped users; the same
85+
// reasoning was never applied to the write, so the row was also invisible to
86+
// its author afterwards.
7687
func (h *Handler) upsertLadderConfig(ctx context.Context, req *events.LambdaFunctionURLRequest) (any, error) {
77-
if _, err := h.requirePermission(ctx, req, "update", "config"); err != nil {
78-
return nil, err
88+
// Named permErr rather than err: the body-parsing and validation steps
89+
// below each bind their own `err` in an if-statement, which would shadow a
90+
// function-scoped `err` and trip govet's shadow check.
91+
session, permErr := h.requirePermission(ctx, req, "update", "config")
92+
if permErr != nil {
93+
return nil, permErr
7994
}
8095

8196
// DisallowUnknownFields so a typo'd key is rejected loudly instead of
@@ -108,7 +123,7 @@ func (h *Handler) upsertLadderConfig(ctx context.Context, req *events.LambdaFunc
108123
return nil, NewClientError(400, fmt.Sprintf("validation error: %s", err))
109124
}
110125

111-
if err := h.validateLadderAccountProvider(ctx, &cfg); err != nil {
126+
if err := h.requireLadderAccountAccess(ctx, session, &cfg); err != nil {
112127
return nil, err
113128
}
114129

@@ -120,18 +135,23 @@ func (h *Handler) upsertLadderConfig(ctx context.Context, req *events.LambdaFunc
120135
return result, nil
121136
}
122137

123-
// validateLadderAccountProvider confirms the (cloud_account_id, provider) pair
124-
// refers to a real cloud account whose provider matches. This turns a
125-
// nonexistent-account FK violation into a clean 400 (rather than a raw 500 from
126-
// the store's FK constraint) and rejects a provider mismatch -- e.g. an inert
127-
// gcp config attached to an AWS account, which would confuse PR-2 scoping.
128-
func (h *Handler) validateLadderAccountProvider(ctx context.Context, cfg *config.LadderConfigDB) error {
129-
account, err := h.config.GetCloudAccount(ctx, cfg.CloudAccountID)
138+
// requireLadderAccountAccess confirms the caller may write ladder config for
139+
// the (cloud_account_id, provider) pair in the request body: the account must
140+
// exist, be within the session's allowed_accounts, and carry the claimed
141+
// provider. It rejects a provider mismatch -- e.g. an inert gcp config attached
142+
// to an AWS account, which would confuse PR-2 scoping.
143+
//
144+
// The account lookup lives in requireAccountAccess, which already fetches the
145+
// account and nil-checks it, so this does not fetch it a second time.
146+
//
147+
// requireAccountAccess answers both "no such account" and "not yours" with the
148+
// same errNotFound (404), deliberately: distinguishing them would let a scoped
149+
// caller enumerate the account table by probing ids. That replaces the earlier
150+
// 400 "cloud account %q does not exist", which leaked exactly that distinction.
151+
func (h *Handler) requireLadderAccountAccess(ctx context.Context, session *Session, cfg *config.LadderConfigDB) error {
152+
account, err := h.requireAccountAccess(ctx, session, cfg.CloudAccountID)
130153
if err != nil {
131-
return fmt.Errorf("failed to look up cloud account: %w", err)
132-
}
133-
if account == nil {
134-
return NewClientError(400, fmt.Sprintf("cloud account %q does not exist", cfg.CloudAccountID))
154+
return err
135155
}
136156
if account.Provider != cfg.Provider {
137157
return NewClientError(400, fmt.Sprintf("provider %q does not match cloud account provider %q", cfg.Provider, account.Provider))

‎internal/api/handler_ladder_test.go‎

Lines changed: 101 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -151,8 +151,14 @@ func TestUpsertLadderConfig_ProviderMismatchRejected(t *testing.T) {
151151
}
152152

153153
// TestUpsertLadderConfig_NonexistentAccountRejected is F5: a cloud_account_id
154-
// that does not resolve must 400 (clean rejection) rather than reaching the
155-
// store and surfacing a raw 500 from the FK constraint.
154+
// that does not resolve must be rejected cleanly rather than reaching the store
155+
// and surfacing a raw 500 from the FK constraint.
156+
//
157+
// The refusal is errNotFound (404) rather than the earlier 400 "cloud account
158+
// does not exist" (issue #1539). requireAccountAccess answers "no such account"
159+
// and "not yours" identically on purpose: a distinguishable 400 would let a
160+
// scoped caller enumerate the account table by probing ids and reading which
161+
// error came back.
156162
func TestUpsertLadderConfig_NonexistentAccountRejected(t *testing.T) {
157163
ctx := context.Background()
158164
handler, mockStore, _ := newLadderHandler(t)
@@ -162,8 +168,98 @@ func TestUpsertLadderConfig_NonexistentAccountRejected(t *testing.T) {
162168
result, err := handler.upsertLadderConfig(ctx, ladderReq(body))
163169
require.Error(t, err)
164170
assert.Nil(t, result)
165-
ce, ok := IsClientError(err)
166-
require.True(t, ok, "expected ClientError, got %T: %v", err, err)
167-
assert.Equal(t, 400, ce.code)
171+
assert.ErrorIs(t, err, errNotFound)
168172
mockStore.AssertNotCalled(t, "UpsertLadderConfig", mock.Anything, mock.Anything)
169173
}
174+
175+
// --- issue #1539: allowed_accounts scoping on the ladder config WRITE path ---
176+
//
177+
// getLadderConfigs has filtered on allowed_accounts since migration 000088
178+
// opened view:config to scoped users; upsertLadderConfig never did. A caller
179+
// scoped to one account could therefore write (and, because the store upserts
180+
// on UNIQUE(cloud_account_id, provider), OVERWRITE) the ladder config of any
181+
// other account -- and the GET then filtered the row back out, so the change
182+
// was invisible to its author afterwards.
183+
//
184+
// Both directions are covered deliberately. A refusal-only test passes just as
185+
// well against a handler that refuses everyone, which would hide the scope
186+
// check having been wired to reject in-scope writes too.
187+
188+
// ladderScopedReq builds an authenticated PUT carrying body, for the principal
189+
// scopedHandler wires (restricted to scopedInAccount).
190+
func ladderScopedReq(body string) *events.LambdaFunctionURLRequest {
191+
return &events.LambdaFunctionURLRequest{
192+
Headers: map[string]string{"Authorization": "Bearer " + scopedToken},
193+
Body: body,
194+
}
195+
}
196+
197+
// ladderScopedBody builds a valid ladder config body targeting accountID.
198+
// mode=auto_approve with max_hourly_commit_per_run omitted (nil => no cap) is
199+
// the shape from the issue's failure scenario.
200+
func ladderScopedBody(accountID string) string {
201+
return `{"cloud_account_id":"` + accountID + `","provider":"aws","enabled":true,` +
202+
`"mode":"auto_approve","cadence":"daily",` + ladderValidRamp + `}`
203+
}
204+
205+
// ladderScopedHandler wires a handler whose session is restricted to
206+
// scopedInAccount, with both cloud accounts resolvable so the refusal comes
207+
// from the scope check rather than from a failed lookup.
208+
func ladderScopedHandler(t *testing.T) (*Handler, *MockConfigStore) {
209+
t.Helper()
210+
h, mockStore := scopedHandler(t, scopedInAccount)
211+
mockStore.On("GetCloudAccount", mock.Anything, scopedInAccount).
212+
Return(&config.CloudAccount{ID: scopedInAccount, Name: "in-scope", Provider: "aws"}, nil).Maybe()
213+
mockStore.On("GetCloudAccount", mock.Anything, scopedOutAccount).
214+
Return(&config.CloudAccount{ID: scopedOutAccount, Name: "victim-account", Provider: "aws"}, nil).Maybe()
215+
return h, mockStore
216+
}
217+
218+
// TestUpsertLadderConfig_OutOfScopeAccountRefused is the guard: this is the
219+
// assertion that fails if the requireAccountAccess call is removed.
220+
func TestUpsertLadderConfig_OutOfScopeAccountRefused(t *testing.T) {
221+
ctx := context.Background()
222+
handler, mockStore := ladderScopedHandler(t)
223+
224+
result, err := handler.upsertLadderConfig(ctx, ladderScopedReq(ladderScopedBody(scopedOutAccount)))
225+
226+
require.Error(t, err, "a caller scoped to another account must not write this config (#1539)")
227+
assert.Nil(t, result)
228+
assert.ErrorIs(t, err, errNotFound,
229+
"must be the enumeration-safe not-found, not a 403 that confirms the account exists")
230+
mockStore.AssertNotCalled(t, "UpsertLadderConfig", mock.Anything, mock.Anything)
231+
}
232+
233+
// TestUpsertLadderConfig_InScopeAccountStillAllowed is the other direction:
234+
// the same restricted principal, the account it IS entitled to. Without this,
235+
// a handler that refused every write would satisfy the test above.
236+
func TestUpsertLadderConfig_InScopeAccountStillAllowed(t *testing.T) {
237+
ctx := context.Background()
238+
handler, mockStore := ladderScopedHandler(t)
239+
mockStore.On("UpsertLadderConfig", mock.Anything, mock.AnythingOfType("*config.LadderConfigDB")).
240+
Return(&config.LadderConfigDB{ID: "written-row", CloudAccountID: scopedInAccount}, nil)
241+
242+
result, err := handler.upsertLadderConfig(ctx, ladderScopedReq(ladderScopedBody(scopedInAccount)))
243+
244+
require.NoError(t, err, "a caller scoped to this account must still be able to write its config")
245+
require.NotNil(t, result)
246+
mockStore.AssertCalled(t, "UpsertLadderConfig")
247+
}
248+
249+
// TestUpsertLadderConfig_UnrestrictedSessionUnaffected pins that the refusal
250+
// above is driven by the caller's SCOPE and not by anything about the account
251+
// itself: an unrestricted session writes that same account unchanged.
252+
func TestUpsertLadderConfig_UnrestrictedSessionUnaffected(t *testing.T) {
253+
ctx := context.Background()
254+
handler, mockStore := scopedHandler(t) // no accounts => grantAdmin => unrestricted
255+
mockStore.On("GetCloudAccount", mock.Anything, scopedOutAccount).
256+
Return(&config.CloudAccount{ID: scopedOutAccount, Name: "victim-account", Provider: "aws"}, nil)
257+
mockStore.On("UpsertLadderConfig", mock.Anything, mock.AnythingOfType("*config.LadderConfigDB")).
258+
Return(&config.LadderConfigDB{ID: "written-row", CloudAccountID: scopedOutAccount}, nil)
259+
260+
result, err := handler.upsertLadderConfig(ctx, ladderScopedReq(ladderScopedBody(scopedOutAccount)))
261+
262+
require.NoError(t, err, "an unrestricted session must be unaffected by the #1539 scope check")
263+
require.NotNil(t, result)
264+
mockStore.AssertCalled(t, "UpsertLadderConfig")
265+
}

0 commit comments

Comments
 (0)