Repository navigation
fix(auth): enforce constraints on an admin-scoped permission on every check - #1096
Conversation
… check
permissionsAllow applied the Constraints of an {admin, *} permission only
when the request set StrictScope. A key created as {admin, *} with
accounts=[A] or a max_amount therefore passed every non-strict constraint
check (requirePermissionConstraints -> HasAPIKeyPermissionForConstraintsAPI)
for account B or for any amount, so the scoping its creator asked for was
silently ignored. The owner gate still bounded it, but an Administrators
owner holds an unconstrained {admin, *} and passes everything.
An admin permission's Constraints now narrow it on every check, the same
as any other permission. Unconstrained admin grants (the seeded
Administrators group) and the money-verb carve-outs are unchanged.
Closes LeanerCloud#956
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Note Currently processing new changes in this PR. This may take a few minutes, please wait... ⚙️ Run configuration
📒 Files selected for processing: 4
✨ Finishing Touches
Comment |
|
Independent review (claude-cc-platform reviewer, cold brief): APPROVE The production change is one line in Real admins are unaffected:
Fail-closed is intact: every store or lookup error still returns Interactions:
Mutation probes (all killed):
Tests and drift: Nit: a request that omits a dimension (for example A separate pre-existing follow-up from this review has been reported to the owner directly. |
|
Independent review (claude-fleet): APPROVE dcb8f58 Findings: none blocking. A non-blocking follow-up outside this diff was reported privately to the maintainers, per SECURITY.md.
Check sites:
Attacked:
Verified locally, on a
Reviewed by an independent agent with no author context. Review only: nothing was pushed, and the GitHub review state is unchanged. |
|
Local verification at
|
|
Marked ready and merged by the owner's agent after 2 independent reviews and local verification (see the review and verification comments above, all at |
…h.APICreateAPIKeyRequest The merge of origin/main brought in apikey_admin_constraints_test.go (LeanerCloud#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>
* 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
permissionsAllow(internal/auth/service_group.go) treated an{admin, *}permission as allowing everything and applied its Constraints only when the request setStrictScope. A key created as{admin, *}withaccounts=[A]and/ormax_amounttherefore passed every non-strict constraint check for account B or for any amount. #957 made those constraints survive key creation, so this became reachable through the API.The non-strict path is the one the API layer runs for most user-API-key money checks:
requirePermissionConstraints→HasAPIKeyPermissionForConstraintsAPI, e.g.enforcePurchaseConstraintsforexecute-any/execute-own/approve-own, none of which are in the admin carve-out set. The owner gate does not help here: an Administrators owner holds an unconstrained{admin, *}and passes everything, so the key's own constraints were the only thing that could refuse the request, and they were skipped.Fix
I took the issue's first option ("apply constraints on an admin permission everywhere"), because it fails closed for keys already stored with constrained admin permissions. Rejecting such permissions at creation would leave those keys unenforced.
permissionsAllow, an admin permission is now checked withcheckPermissionConstraints(perm, constraints)on every check, not only underStrictScope. This is the same rule every other permission already follows.{admin, *}(the seeded Administrators group) still passes, becausecheckPermissionConstraintspasses a permission that has no Constraints.nilconstraints (grant ceilings,HasPermission) behave as before.sessionHoldsVerb,[]PermissionConstraints{{}}) still reports the verb as held, because an empty request dimension counts as unspecified.validateAPIKeyPermissionsstill checks only action and resource at creation. Its doc comment now says that constraints are enforced at check time, against both the key and the owner, and that this includes{admin, *}. After this change, admin-key constraints are no longer silently ignored on these checks.The same rule now also narrows a group permission
{admin, *}that carries Constraints on the session path (HasPermissionForConstraintsAPI). Strict checks already narrowed it, so this only makes the non-strict path consistent.No migration and no DB change, so there is no concurrency surface.
Tests
internal/auth/admin_constraints_test.goTestPermissionsAllow_ConstrainedAdminNonStrict: non-strict checks against a constrained admin permission, one row per dimension (account, provider, service, region, amount), plus allow controls and the empty probe.TestPermissionsAllow_AdminWithoutConstraintsUnchanged: an unconstrained admin still passes, and carve-outs still deny.TestPermissionsAllow_ConstrainedAdminAlongsideExplicitGrant: a constrained admin permission next to an explicit grant.TestHasAPIKeyPermissionForConstraintsAPI_ConstrainedAdminKey: a{admin,*}key withaccounts=[acct-a]andmax 500under an Administrators owner. Account B is refused, an amount over the cap is refused, and an in-scope request is allowed.TestHasPermissionForConstraintsAPI_ConstrainedAdminGroup: the session path with a group-granted constrained admin permission.internal/server/apikey_admin_constraints_test.go(TestAuthServiceAdapter_ConstrainedAdminKeyRefusedOutOfScope): creates the key through the realCreateAPIKeyAPIadapter andauth.Service, with the store mocked, as{admin,*}+accounts=[acct-a]+max_amount=500. It then checks the stored key throughHasAPIKeyPermissionForConstraintsAPI. Account B and $501 are refused; account A at $500 is allowed.Fail before / pass after: I reverted only
service_group.gotoorigin/mainwith the new tests in place. These failed:ConstrainedAdminNonStrict(other account, provider, service, region, over cap), the two deny rows ofConstrainedAdminKey(account B, over the key cap),ConstrainedAdminGroupandConstrainedAdminAlongsideExplicitGrant. The fifth test,AdminWithoutConstraintsUnchanged, is a control and passed, as intended;Every allow control passed. All pass with the fix applied.
Verification (local, macOS)
All evidence here is unit, mock and fixture evidence. I ran tests with
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/nullbecause this machine's default AWS profile is invalid. No real cloud calls were made.go build ./...andgo vet ./...: clean.go test -count=1 ./internal/auth/ ./internal/server/ ./internal/api/ ./internal/mocks/: pass.go test -tags integration -count=1 ./internal/server/ -run TestConfigPermissionConstraintsHTTPand./internal/api/ -run TestMarketplacePermissionConstraintsHTTP: pass. These run on real Postgres via testcontainers and include the existingadmin/admin-boundedcases.--allow-parallel-runners) on./internal/auth/... ./internal/server/...: 0 issues.gocyclo -over 10 -ignore "_test\.go" internal/auth/: silent.scripts/gosec-hook.shon the changed files: exit 0.--register-aws) scan of all tracked files, run in an isolated fresh repo: clean.scripts/test-git-secrets-allowlist.sh: 42 PASS, 0 FAIL.Real-environment gap: I did not run an end-to-end HTTP request with a constrained admin key against a deployed instance. The server-level test exercises the real create path and the real check, with a mocked store.
Out of scope (noted, not changed)
getAccountScopederives the account scope for read endpoints from the owner's groupallowed_accounts, for every key type. A key's permissionAccountIDsconstraint therefore never narrows what the key can read. Permission Constraints in general (for users and keys alike) are enforced only where handlers callrequirePermissionConstraints. That is a broader design question than this issue.UserHasAdminCapabilityandpermissionsResponse(IsAdmin) still treat any{admin, *}as admin regardless of its Constraints. They answer action/resource-level questions only, so I left them alone.Closes #956
Maintainer: please mirror the issue's triage labels onto this PR:
priority/p2,severity/medium,triaged,urgency/this-quarter,impact/few,effort/s,type/security.🤖 Generated with Claude Code
Summary by CodeRabbit
Bug Fixes
Tests