Repository navigation
fix(auth): reject an explicit zero spend cap on group writes - #983
Conversation
An explicit max_amount of 0 on a group permission was stored as 0, which the enforcement path reads as "no cap". Typing 0 into a cap field reads as "spend nothing", so the group got unlimited spend. Owner decision on #133: the API rejects an explicit 0 and absent means no cap. APIPermissionConstraint now records whether max_amount was present and non-null when decoded from JSON (unexported maxAmountSet, set by UnmarshalJSON; null counts as absent). apiPermissionsToPermissions returns validateExplicitZeroCap's ErrInvalidPermission, which the group handlers already map to 400. apiPermissionToPermission keeps its signature. #940's negative/NaN validation is reused unchanged. Scope is group writes only. CreateAPIKeyAPI shares the adapter, but the HTTP key handler decodes into api.Permission, which erases an explicit 0 before it reaches the auth layer; that gap is tracked in #980. Tests: auth unit tests (0, 0.0, -0, 0e5 rejected on create/update; absent, null, 5000, 1234.5 accepted) and a handler test driving the real api.Handler into a real auth.Service on a MockStore. OpenAPI marks max_amount exclusiveMinimum 0 (3.0.3 form) and documents it as group writes. Refs #133 (backend half; the frontend input handling follows separately). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@coderabbitai review |
|
Warning Review limit reached
This review includes 7 billable files and costs up to $1.75.
Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing. Or wait 3 minutes for your next included review. View limit details
Comment |
|
cristim
left a comment
There was a problem hiding this comment.
Independent review of 0ad75eb: changes requested
Findings
- BLOCKING (gate): PR body line "The frontend editor change follows in a separate PR, which closes #133" makes GitHub list #133 under closingIssuesReferences (verified via
gh pr view). Merging this backend-only PR auto-closes #133 before the UI half lands. Fix: reword the body to "Refs #133" / "Part of #133" with no closing keyword, then re-check that closingIssuesReferences is empty. No code change, so no new SHA. - NON-BLOCKING: HTTP API-key create is not covered (api.Permission erases the explicit 0 in handler_apikeys.go:60-74 before auth sees it). Honestly disclosed in the body, CHANGELOG and openapi, tracked in #980. Note the openapi
exclusiveMinimumis on the group schema only; key creation uses a different schema, so no false contract claim.
What I attacked
- Explicit vs absent survives decode: UnmarshalJSON (service_api.go:69) sets maxAmountSet from a *float64 probe, so 0, 0.0, -0, 0e5 are set; absent and null are not. Quoted "0" fails the float64 decode as before (400). No handler uses DisallowUnknownFields or UseNumber on these types, so the inner json.Unmarshal loses no decoder options. Marshal is untouched (omitempty), so GET output re-PUT round-trips.
- Paths: create (service_api.go:341), update (updateRequestPermissions, order unchanged: system-managed, empty-permissions, zero check, then checkGrantCeiling) and CreateAPIKeyAPI (service_apikeys_api.go:213) all go through the one converter. Handlers decode with json.Unmarshal into the auth types (handler_groups.go:47,110).
- Stored rows: no migration and no read-path change; stored 0 still means no cap on reads (validateMaxPurchaseAmount and #940's negative/NaN handling untouched, same ErrInvalidPermission 400 mapping). Intended per the owner decision; existing zero-cap groups stay unlimited and only a re-write is refused.
- Fail-before/pass-after: with production files reset to origin/main, all 8 TestGroupWrite_RejectsExplicitZeroMaxAmount subtests and the API-key test fail; at the SHA
go test ./internal/auth/... ./internal/api/...passes (4319 tests).
Gate status
- Independent review: this comment, names the SHA, one blocking gate item above.
- CI on the SHA: still pending at review time (Unit, Lint, pre-commit, E2E); CodeRabbit is rate limited with no threads. Not yet green.
- Labels: priority/p2, urgency/this-sprint, severity/high, impact/few, effort/s, type/bug, triaged. I did not diff them against #133, so that is unverified here.
- Local verification: auth and api tests with fail-before proof, run on macOS. No real-account verification.
|
CI on 0ad75eb is red for a reason in this PR. Unit and Integration both fail
The guard rejects AssertNotCalled on a receiver whose mock type it can't resolve, because such an assertion can never fail. Declare Separately, as noted on the bus: GitHub still lists #133 as a closing issue. The sentence "...in a separate PR, which closes #133" triggers that. Please reword it, e.g. "Refs #133; the frontend PR will close it", so that merging this backend half doesn't close #133. The independent code review (no code defects) covers 0ad75eb. A new commit gets a delta review. |
…st store The guard only resolves receivers built from a bare type name, so new(auth.MockStore) left two AssertNotCalled sites unchecked. Alias the type locally so they are checked; the assertions are unchanged and both methods exist on auth.MockStore. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
CI fix at 84a8218 (test-only, +6/-1): |
cristim
left a comment
There was a problem hiding this comment.
Independent delta review of 84a8218 (previous: 0ad75eb): changes requested
OLD_SHA is an ancestor of NEW_SHA. The delta is one file, internal/api/group_explicit_zero_cap_test.go (+6 -1): type MockStore = auth.MockStore and new(MockStore) at line 61 (was new(auth.MockStore)).
Findings
- BLOCKING (gate):
gh pr view 983 --json closingIssuesReferencesstill lists #133. The body still says "The frontend editor change follows in a separate PR, which closes #133", so merging this backend-only PR would auto-close #133 before the frontend work lands. Fix: reword so it does not use a closing keyword, for example "Part of #133; the frontend PR will close it", then re-check closingIssuesReferences is empty. - RESOLVED: TestNoUnfailableMockAssertions failure. The alias gives the guard a bare type name to resolve for the AssertNotCalled sites at lines 76-77.
go test ./internal/mocks/passes. - Code review at OLD_SHA found no defects; the delta changes no production code.
What I attacked
- Weakening: the assertions are unchanged and the alias is the same type as auth.MockStore, so semantics are identical.
- Probe 1: disabled validateExplicitZeroCap in internal/auth/purchase_amount_cap.go. All four explicit-zero subtests (POST and PUT,
0and0.0) fail (error expected but got nil). - Probe 2: inserted direct store.CreateGroup and store.UpdateGroup calls before the assertions. Both AssertNotCalled calls fail ("Should not have called with given arguments") in every explicit-zero subtest. Both probes reverted; worktree clean.
go test -count=1 ./internal/mocks/ ./internal/api/ ./internal/auth/...passes.
Gate status
- CI on NEW_SHA: most checks pass (pre-commit, terraform, Snyk and the other scope checks); Unit Tests, Lint Code, Integration Tests, E2E, Docker build and Security Scanning were still pending when I looked. No action_required runs seen. I did not wait.
- CodeRabbit: rate limited, no threads; not pinged.
- Closing reference: fails, see above. Verdict and this review are recorded here against NEW_SHA.
|
Merge driver status (claude-cc-pr-merge, 2026-10-17T15:25:27Z): code gate met, merge BLOCKED on the author: the body still says the frontend PR "closes #133", so GitHub lists #133 as a closing issue and would close it on merge; reword without a closing keyword (for example "Part of #133; the frontend PR will resolve it"). |
* fix(api): keep an explicit max_amount of 0 on API-key create so it is rejected POST /api/api-keys decoded the body into api.CreateAPIKeyRequest, whose constraint max_amount is a plain float64 with omitempty, and the service then re-encoded it through JSON into auth.APICreateAPIKeyRequest. The explicit 0 was erased on that re-encode, so APIPermissionConstraint's decode-time maxAmountSet flag was never set, validateExplicitZeroCap (#133/#983) never fired, and the key was minted with no spend cap. That fails open on a money path: typing 0 means "spend nothing". The handler now decodes straight into auth.APICreateAPIKeyRequest, as the group handlers do, and CreateAPIKeyAPI takes that concrete type instead of any. The JSON re-encode (the #1440 workaround noted in #950) is gone, so no other caller type can reintroduce the erasure; the compiler now enforces what the re-encode used to paper over. The unused api.CreateAPIKeyRequest is removed. An explicit 0 (0, 0.0, -0, 0e5) now returns 400 and no key is stored. Absent and null still mean no cap, and positive caps are stored as sent. Tests: HTTP-level tests drive the real handler into a real auth.Service (MockStore). The four rejection subtests fail on main with 200 and a stored key. The obsolete cross-package re-encode test is dropped (the typed signature makes it a compile-time guarantee) and the adapter constraint test uses the auth wire types. Closes #980 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> (cherry picked from commit 102d3a2) * test(server): port #1096 admin-key constraints test to auth.APICreateAPIKeyRequest The merge of origin/main brought in apikey_admin_constraints_test.go (#1096), which still built the removed api.CreateAPIKeyRequest. Retype it to auth.APICreateAPIKeyRequest / auth.APIPermission / auth.APIPermissionConstraint so internal/server builds on the merged tree. A Go literal leaves maxAmountSet false, so the 500 cap is kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> (cherry picked from commit 57c5e78) * test(api): use auth.APICreateAPIKeyRequest in the #1089 createAPIKeyHandler coverage case main's #1089 (5c2dcbd) added a TestRouter_Handlers_Coverage subtest that builds the removed api.CreateAPIKeyRequest. This branch types CreateAPIKeyAPI as auth.APICreateAPIKeyRequest, so the merged tree failed go vet with "undefined: CreateAPIKeyRequest". Port the expectation to the auth wire type; the JSON body, the decoded password and the asserted response are unchanged. Refs #980 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> (cherry picked from commit e425b10) --------- Co-authored-by: Astrid Gealer <astrid@gealer.email> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Summary
Part of #133 (backend). The frontend editor change follows in a separate PR, which will resolve the issue.
Owner decision on #133 (2026-10-16):
max_amount: 0with a 400.Before this change, an admin who sent 0 meaning "may not spend" got an unlimited cap.
PermissionConstraints.MaxPurchaseAmountis a plain float64, and 0 means no cap in every reader.#940 (12c2663) already rejects negative and NaN caps on writes and fails closed on stored ones. This PR reuses its helpers in
internal/auth/purchase_amount_cap.goand adds only the explicit-zero case:APIPermissionConstraintgets an unexportedmaxAmountSetflag, set by a customUnmarshalJSON. The flag separates an explicitmax_amount(including0,0.0,-0,0e5) from an absent field ornull. Marshal output is unchanged, so responses are identical. Go literals never set the flag and are never rejected.validateExplicitZeroCapruns in the sharedapiPermissionsToPermissions, which now returns an error. It runs beforecheckGrantCeiling.ErrInvalidPermissionis already a 400 viamapGroupAuthError.apiPermissionToPermissionkeeps its signature.UpdateGroupAPIis split into anupdateRequestPermissionshelper to stay under gocyclo 10. The order is unchanged: system-managed refusal, empty-permissions check, conversion and zero check, then the ceilings.openapi.yaml:PermissionConstraint.max_amountgetsminimum: 0andexclusiveMinimum: true(the OpenAPI 3.0.3 form). The description says 0 is rejected on group writes and that absent means no cap.Scope: group writes only. The HTTP API-key create path decodes into
api.Permission, which erases an explicit 0 before the auth layer sees it. That gap is tracked in #980, and the wording here does not claim key coverage.Tests
internal/auth/explicit_zero_cap_test.go:0,0.0,-0and0e5are rejected on create and update.null,5000and1234.5are accepted.internal/api/group_explicit_zero_cap_test.go: drives the realcreateGroupandupdateGrouphandlers (the router calls them directly; the body decode is in the handler) with a realauth.ServiceonMockStore.0or0.0return 400 with the message.5000are accepted.nullas set;Verification (local, macOS)
go build ./...andgo vet ./...pass.go test -race -count=1 ./internal/auth/... ./internal/api/... ./internal/server/...passes, and the test(auth): fail when an API adapter drops a request field #923 field-coverage tests still pass.GOTOOLCHAIN=go1.26.9 go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.10.1 runon auth and api: 0 issues. The pre-commit gocyclo hook passes.Review
plans/133-spend-cap-zero.md, reviewed independently through several revisions. It was re-scoped after fix(auth): fail closed on a negative MaxPurchaseAmount spend cap #940 and fix(auth): keep API key permission constraints on create #957.🤖 Generated with Claude Code