Repository navigation
fix(auth): fail closed on a negative MaxPurchaseAmount spend cap - #940
Conversation
matchPurchaseAmountConstraint gated on permMax > 0, so a permission whose stored MaxPurchaseAmount was negative (or NaN) read as "no cap" and allowed any purchase. A malformed cap now denies every request at enforcement, covering rows already stored that way. The same predicate is applied to the grant ceiling (amountCovers), which otherwise let a group edit carry a stored negative cap through as an uncapped permission, and to the group and API-key write paths, which now refuse a negative/NaN cap with ErrInvalidPermission (400). Zero keeps its existing meaning of "no cap". The cap helpers move to purchase_amount_cap.go to keep service_group.go under the 500-line guideline. Closes LeanerCloud#83 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The inline password literal tripped the git-secrets password detector; the package already has a shared test password constant for this. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Pushed 7dc1891 to address the CI failures:
Also re-run locally: |
cristim
left a comment
There was a problem hiding this comment.
Independent review of 7dc1891: draft: blocks ready
No BLOCKING findings.
NON-BLOCKING
- internal/auth/group_ceiling.go is now 519 lines (guideline 500); it was 511 before. Additions are 422 (guideline 400). Both are guidelines only.
- Existing stored negative cap on a carved-out money verb:
amountCovers(group_ceiling.go:511) now returns false for it, so that entry cannot be carried through an edit, only dropped. This is the intended fail-closed behavior and is documented in the PR body. The cap cannot reach the DB through the API today (JSON negative is now 400), so such rows only exist from manual DB edits; no migration is needed. Worth one line in the runbook if an operator hits it. matchPurchaseAmountConstraintdoes not reject a negative or NaNreqMax(the requested amount). Out of scope for #83 (reqMax is computed server-side from recommendation totals), noted only.
What I attacked (worktree at the head SHA, go test ./internal/auth/... ./internal/api/ all pass, 4052 tests)
- Every reader of the cap:
matchConstraints(service_group.go:408) is the only enforcement call site; web execute, approve, retry, Azure RI-exchange and API-key paths all reach it. No other reader ofMaxPurchaseAmountoutside auth/api (grep of internal/ and cmd/; no scheduler or SQL reader, no migration seeds a cap). - Semantics: 0 still means "no cap" (
permMax > 0guard kept); negative and NaN deny;amountCoversheld 0 still covers anything, held negative covers nothing. No path turns an unset cap into deny-all. - Write paths:
validateConstraintEntries(group create/update viacheckGrantCeiling, service_api.go:318 and :391) andvalidateCreateAPIKeyRequest(service_apikeys.go:161) reject negative and NaN;mapCreateAPIKeyErrormapsErrInvalidPermissionto 400. - Existing negative rows: a non-carved-out permission with a stored negative cap now denies all requests (fail closed, as #83 asks) and can be repaired by resubmitting a zero or positive cap; only the carved-out verbs are drop-only.
- Mutation probe 1: make
invalidPurchaseAmountCapignore negatives. Fails TestMatchPurchaseAmountConstraint_NegativeCapDeniesAll, TestService_HasPermissionForConstraintsAPI_NegativeStoredCap, TestGroupWrite_RejectsNegativeMaxAmount, TestCreateAPIKey_NegativeMaxPurchaseAmount. Reverted. - Fail-before: relied on the PR's own recorded run plus probe 1 (the same predicate gates all new tests); I did not rebuild the parent.
- The API-key constraint re-encode drop the PR author mentions (api.PermissionConstraint vs auth.Permission tags) is a separate, pre-existing issue and should get its own issue; it is not made worse here.
Gate status (origin/main Review gate)
- Independent review: this comment, against the SHA above.
- CI on the SHA: all checks pass (Unit, Integration, E2E, Lint, pre-commit, Security, gosec, Trivy). CodeRabbit skipped (draft); no review threads exist.
- Labels: match issue #83 (effort/s, impact/internal, priority/p3, severity/low, triaged, type/security, urgency/eventually).
- Local verification: unit and mock tests only, no real Postgres-stored row or cloud account; realistic mock-store fixtures cover the stored-negative case. Reviewer did not exercise a real DB.
Blocks ready
- Fail-closed predicate shared by enforcement, grant ceiling and both write paths.
- Fail-before/pass-after tests present and the probe confirms they bite.
- CI green on this SHA; labels mirrored.
|
Gate record for 7dc1891 (origin/main CLAUDE.md "Review gate"):
Merging at 7dc1891 with --match-head-commit. |
|
Follow-up to the review of 7dc1891. New head: 91679e2 (two new commits, no force-push). 1. 2.
3. Operator note for a stored negative cap on a carved-out verb. I looked for a runbook to put it in. The only fitting place is the Troubleshooting section of API-key constraint drop: this is tracked as #951 and has its own PR. It is not touched here. I updated the PR body to say so, and removed the label request now that the labels are mirrored. Verification (unit/mock evidence only; no real DB, cloud account, purchase or deploy)All commands ran with Fail before / pass after for note 2. The test was added and run before the fix: With the fix, that test passes. All earlier #83 tests ( 🤖 Generated with Claude Code |
|
This merged at 7dc1891 just before the follow-up commits for the non-blocking notes (91679e2) reached this branch. Those commits are therefore not in main. They are now in #954 (tracked by #953), cherry-picked unchanged onto current main, and that PR has the full verification. You can ignore my earlier point-by-point comment on this PR. 🤖 Generated with Claude Code |
…ormed requested amount under a cap (#954) * refactor(auth): move grant-ceiling coverage helpers into their own file Pure move of constraintsCover, listCovers and amountCovers from group_ceiling.go into grant_ceiling_cover.go (same package, bodies and comments unchanged) so group_ceiling.go is back under the 500-line guideline (519 -> 448). No behavior change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(auth): deny a malformed requested amount when a spend cap applies matchPurchaseAmountConstraint only validated the stored cap. A NaN requested amount compares false against the cap and a negative one sits below it, so either passed a capped permission. When a cap applies, a negative, NaN or infinite requested amount now denies. With no cap (permMax == 0) the amount dimension stays unrestricted, matching a permission with no constraints at all, which never reaches the matcher. reqMax is computed server-side, so this is defense in depth; no caller changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(auth): reject an explicit zero spend cap on group writes 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> * test(api): let the mock assertion guard resolve the group zero-cap test 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> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Root cause
matchPurchaseAmountConstraintonly applied the cap whenpermMax > 0. A permission whose storedMaxPurchaseAmountwas negative fell through to "no constraint" and allowed any amount. NaN did the same, because it compares false against everything. The grant ceiling'samountCovershad the same flaw (heldMax <= 0returned "covers"). Nothing on the group or API-key write paths rejected a negative cap.Fix
The issue asks for this to fail closed. One predicate,
invalidPurchaseAmountCap(v) = v < 0 || NaN, is now used everywhere the cap is read or written:matchPurchaseAmountConstraint): a malformed cap denies every request. This covers rows that are already stored negative. Every money path goes through this matcher: web execute, retry, execute-any/own, approve-any/own, runPlannedPurchase (all viaenforcePurchaseConstraints), the Azure RI-exchange execute path, and API-key sessions (HasAPIKeyPermissionForConstraintsAPI).amountCovers): a malformed held cap covers nothing. Without this, an edit could carry a stored negative cap on a carved-out money verb (for exampleexecute:purchases) through as an uncapped permission, becausepermissionCoveredByread the negative cap as "no cap". A ceiling must never be looser than enforcement.validateConstraintEntries) and API-key create (validateCreateAPIKeyRequest) refuse a negative or NaN cap withErrInvalidPermission. The group handler already maps that error to 400, andmapCreateAPIKeyErrornow maps it to 400 too.matchPurchaseAmountConstraint, follow-up to review): when a cap applies, a negative, NaN or infinite requested amount is denied too (a NaN request used to compare false against the cap and pass). With no cap the amount dimension stays unrestricted, matching a permission with no constraints at all, which never reaches the matcher. The requested amount is computed server-side, so this is defense in depth.The cap helpers now live in a new
internal/auth/purchase_amount_cap.go. Moving them keepsservice_group.gounder the 500-line guideline (466 lines). The grant-ceiling coverage helpers (constraintsCover,listCovers,amountCovers) moved unchanged into a newinternal/auth/grant_ceiling_cover.go, which bringsgroup_ceiling.gofrom 519 to 448 lines.Closes #83
Verification
All evidence comes from unit and mock tests. No real cloud calls, purchases or deploys. The api-package tests use
MockAuthService, which answers constraint checks with the realauth.PermissionsAllowForConstraintSets, so they exercise the production matcher. They callenforcePurchaseConstraintsandcheckAzureExecuteConstraintsdirectly, with no purchase manager wired.Commands (run with
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null, because this machine's default AWS profile is invalid):Fail before / pass after. I stashed the non-test changes and ran the new tests against unchanged main:
With the fix, all of these pass. The valid-cap controls (a positive cap enforced at and over the limit, and zero meaning no cap) pass both before and after, which shows the fix changes only the malformed case.
Coverage gap: I did not exercise this against a real Postgres-stored group row or a real cloud account. Malformed stored rows are covered by mock-store fixtures.
Behavior notes and out of scope
MaxPurchaseAmountcan only exist from a manual DB edit, because the API now refuses to write one. On an ordinary permission, resubmit it with a zero (no cap) or positive cap. On a carved-out money verb (execute:purchases,approve-any:purchases,retry-any:purchases,execute:ri-exchange, or a*resource on those actions), the edit must drop the entry; restore it with a valid cap through the same SQL path that seeded the group. This note lives here rather than in a doc because the only fitting runbook, the Troubleshooting section ofdocs/DEPLOYMENT.md, is already 768 lines, over the 500-line guideline.api.CreateAPIKeyRequest.Permissionsusesapi.PermissionConstraint, whose JSON tags areaccountsandmax_amount.CreateAPIKeyAPIre-encodes the request intoauth.Permission, whose tags areaccount_idsandmax_purchase_amount. By reading the code, it looks like API-key constraints sent over HTTP are silently dropped during that re-encode. The owner's group constraints are still enforced separately. This is now tracked as API-key create drops accounts/max_amount constraints (JSON tag mismatch between api and auth types) #951, with its own PR.groupModals.tssilently skips a negativemax_amountincollectPermissions. The input'smin="0"and native form validation (nonovalidate) already block it in a real browser, and the backend now refuses it anyway, so I left the frontend alone.🤖 Generated with Claude Code
Summary by CodeRabbit