Repository navigation
fix(api/auth): report a user API key's effective permissions on /me/permissions - #1026
Merged
Merged
Conversation
…ermissions
GET /api/auth/me/permissions resolved a user-API-key caller to its owning
user and returned the owner's full group permissions, so a view-only key
owned by an administrator was told it held {admin, *} with is_admin=true.
Enforcement already used the key's effective set (key permissions
intersected with the owner's), so the endpoint advertised a surface the key
could not use.
For a user-API-key principal the handler now returns the key's effective
permissions through a new auth.Service.GetAPIKeyEffectivePermissionsAPI
(the same ComputeEffectivePermissions path HasAPIKeyPermissionAPI
enforces), and derives is_admin from that set. The key is re-checked
(owner match, revocation, expiry, owner active, password rotation) and any
lookup failure refuses the request instead of falling back to the owner's
permissions. Bearer sessions and the admin API key are unchanged.
Closes LeanerCloud#62
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
|
Warning Review limit reached
This review includes 10 billable files and costs up to $2.50. Or wait 38 minutes for your next included review. View limit details
Comment |
cristim
reviewed
Oct 16, 2026
cristim
left a comment
Member
There was a problem hiding this comment.
Independent review of c6594df: draft: blocks ready
No code defects found in the auth logic. The blocks are gate items only (CI not finished, closing link not machine-readable).
Findings
- BLOCKING (gate, not code):
closingIssuesReferencesis empty. The onlyCloses #62in the body sits inside backticks in the "Out of scope" note, which GitHub does not parse as a closing keyword. Put an unquotedCloses #62on its own line. Issue #62 exists, is OPEN, and its labels (priority/p2, severity/medium, triaged, urgency/this-quarter, impact/few, effort/s, type/bug) match the PR's exactly. - NON-BLOCKING,
internal/auth/service_apikeys_api.go:462-491:GetAPIKeyEffectivePermissionsAPIrepeats the post-hash checks ofValidateUserAPIKey(service_apikeys.go:379-410:validateAPIKeyStatus,lookupAPIKeyUser, password-version compare). Today the two agree line by line, and both end in the sameComputeEffectivePermissions. A later change to one validation list could drift from the other. Suggested follow-up: extract avalidateLoadedAPIKey(ctx, key)used by both. Not required for this PR. - NON-BLOCKING,
internal/api/handler_auth.go:208-216: a key revoked or rotated between authentication and this call returns 500 (generic error mapped athandler.go:846). Enforcement (authorizeAPIKey,handler.go:269-275) treats the same case as "fall through" to 401. Fail-closed either way, nothing leaks; 401 would be the more accurate status. - NON-BLOCKING, size: 592 additions (about 128 production, about 464 tests and mock wiring). Over the 400 guideline but mostly tests.
handler_auth.gogoes from 668 to 732 lines (already over 500 on main).
Reporting vs enforcement (line by line)
- Enforcement:
HasAPIKeyPermissionAPI(service_apikeys_api.go:383-401) =ValidateUserAPIKeythenComputeEffectivePermissionsthenAuthContext.HasPermissionover that set. - Reporting: same
ComputeEffectivePermissions(service_apikeys.go:581, viacomputeEffectivePermissionsFromAuthCtx, which keeps a key entry only if the owner'sHasPermissionholds it), preceded by the same status, owner-active and password-version checks, plus an owner-id match. The reported set can therefore never exceed whatHasPermissiongrants on the enforced set. - Admin cases checked: key
{admin,*}on an admin owner reports admin (enforcement grants everything except the carved-out money verbs, same as a session admin). Key{execute,purchases}on an admin-only owner is dropped in both paths (carve-out,types.go:174-190). Unscoped legacy key gives an empty list in both paths. Key{view,*}on an owner with only{view,recommendations}is dropped in both. - Caller selection mirrors
requirePermission(handler.go:250-288): a user-key principal wins over a bearer session, an admin key short-circuits first, and an unrouted call with an invalid key falls through to the session. The key ID and owner come from the validated principal (middleware.go:132-168, which rejects an empty key ID), and the service re-loads the key by ID and requireskey.UserID == userID, so no cross-user or cross-tenant key can be named by a caller. - Constraints are returned by the service but not serialized (
PermissionEntryhas only action and resource), so the wire cannot overstate a limit. Disabled owner, revoked or expired key, rotated password and group-lookup failure all return an error; no path falls back to the owner's set.
What I attacked
- Built and ran
go test ./internal/api ./internal/auth ./internal/serverat this SHA: all pass;go vetclean. - Fail-before: restored the base
handler_auth.gowith the new service method kept: 5 tests fail (..._UserAPIKey_ReturnsEffectiveScope,..._UserAPIKeyWithOwnerSession_ReportsKey,..._ConstrainedKey_ReportsKeyEntries,..._UserAPIKeyLookupFailureRefuses,TestHandler_getCurrentUserPermissions_UserAPIKey). - Mutation probes in
GetAPIKeyEffectivePermissionsAPI, each reverted at once (worktree clean afterwards): return the key's permissions without the owner intersection (killed by..._KeyConstraintsAndIntersection,..._FailsClosed, and the constrained-key API test); dropvalidateAPIKeyStatus(killed); drop the password-version check (killed); drop the owner-id match (killed); swaplookupAPIKeyUserfor a rawGetUserByID, so an inactive owner passes (killed). 6 of 6 killed. - Tried to find a way to report more than is enforced (wildcards, carve-outs, session plus key, admin key, nil permissions, owner without groups, another user's key ID): none survived.
Gate status (origin/main Review gate)
- Independent adversarial review of the full diff naming the SHA: this comment.
- Actionable findings resolved: none open from me. CodeRabbit skipped the draft ("Review skipped: draft pull request"); I did not ping it. No CR threads exist.
- CI on the SHA: not green yet. The pre-commit check passed; "CI - Build & Test" is queued and about 20 checks (Unit Tests, Lint Code, Integration Tests, E2E, Security Scanning, Terraform validations) are pending. No
action_requiredfork runs were seen at review time. BLOCKS READY until all are green. - Local verification on macOS: real
auth.Serviceoverauth.MockStorethroughHandleRequest; fail-before and mutation evidence above. No real-account verification (none needed for this read-only endpoint). - Labels: match issue #62 exactly; the closing keyword needs fixing (finding 1).
- Verdict, SHA and evidence recorded here.
Blocks ready
- CI green on c6594df (queued or pending at review time).
- Unquoted
Closes #62so GitHub links the issue. - Take the PR out of draft; any new commit or rebase restarts this gate.
Member
|
Gate record for c6594df (origin/main CLAUDE.md "Review gate"):
Merging at c6594df with --match-head-commit. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Root cause
GET /api/auth/me/permissions(getCurrentUserPermissions,internal/api/handler_auth.go) resolved every caller to a user ID throughresolveAuthenticatedUserID, then calledGetUserPermissionsAPI(userID). For a user API key, that ID belongs to the key's owner, so the endpoint returned the owner's full group permissions and anis_adminvalue based on the owner. Aview:recommendations-only key owned by an administrator was told it held{admin, *}withis_admin: true. Enforcement (requirePermission->HasAPIKeyPermissionAPI->ComputeEffectivePermissions) already used the key's effective set, so the endpoint advertised permissions the key could not use. I re-checked this on currentmain(110a4968) before changing anything: no commit or merged PR references #62, and the handler still had the owner-permissions code.Fix
auth.Service.GetAPIKeyEffectivePermissionsAPI(ctx, keyID, userID)(new,internal/auth/service_apikeys_api.go): loads the key by ID and re-checks it: the owner must match, the key must not be revoked or expired, the owner must be active, and the owner's password version must match. It then returnsComputeEffectivePermissions(key, owner), the same intersectionHasAPIKeyPermissionAPIenforces. Each entry keeps the key's constraints, not the owner's. Any failure returns an error.APIKeyID/UserID, whichprincipalFromUserAPIKeysets on every routed request, and returns the key's effective set.is_adminis derived from that set. A key sent together with a bearer session is reported as the key, because that is howrequirePermissionevaluates it. For direct (unrouted) calls, if a key validates but no usable key record comes back, the request is refused rather than described with the owner's permissions.AuthServiceInterfacegains the method, and the server adapter and test mocks are wired. The response shape is unchanged.Before / after (
GET /api/auth/me/permissions)view:recommendations[{admin,*}],is_admin: true[{view,recommendations}],is_admin: false{execute:purchases (max $100, aws), delete:users}; owner lacksdelete:usersbut has more[{execute,purchases}]{admin,*}on an admin owner[{admin,*}],true[{admin,*}],true(follows the effective set)[],false(deny-by-default, #61)[{admin,*}],trueFrontend impact
None expected;
frontend/is untouched. The SPA authenticates with a bearer session, and that path is unchanged. It also callsGET /api/auth/mefirst, which rejects API-key-only callers (see out of scope), so the SPA never shows UI based on a key's permissions today. If an API-key client starts reading this endpoint, it now gets the key's real surface. That fixes the issue's CLI/integration case, where clients would otherwise try admin endpoints and collect a wave of 403s.Verification (local, macOS, at
c6594dfb)internal/api/handler_auth_me_permissions_apikey_test.godrivesHandleRequest(middleware, router, handler) into a realauth.Servicebacked byauth.MockStore. It covers: an admin owner with a view-only key (TestGetCurrentUserPermissions_UserAPIKey_ReturnsEffectiveScope), a key plus the owner's session, the owner's session alone (unchanged), the admin API key (unchanged), an admin-scoped key, a constrained key, and a lookup error or revocation mid-request (refused). For each key case it also asserts that every reported entry is allowed byHasAPIKeyPermissionAPIand thatis_adminmatches what enforcement grants foradmin:*, so the reported and enforced sets cannot drift apart (sec(auth): unscoped user API key silently inherits owner's full permissions #61/fix(api/auth): /me/permissions returns owner's full perms instead of API key's effective scope #62).internal/auth/service_apikeys_effective_test.gochecks the intersection, that surviving entries carry the key's constraints (max $100 andawssurvive, not the owner's $1000), that an unscoped key gets an empty set, and that 8 failure cases fail closed (key lookup error, key not found, owner mismatch, revoked, expired, inactive owner, password rotated, group lookup error).TestHandler_getCurrentUserPermissions_UserAPIKey,TestRouterAuthUser_UserAPIKeyPrincipalAvoidsSecondValidation,TestHandleRequest_UserAPIKeyPrincipalValidatedOnce), and one was added (..._UserAPIKeyWithoutKeyRecordRefuses).handler_auth.go, 4 of the 7 new HandleRequest tests fail. The returned values were[{admin,*}]/trueinstead of the key's set, the owner's three permissions instead of one, and 200 instead of 500. The 3 unchanged-path tests pass both before and after. All pass with the fix.is_adminforced false (3); nil key record falls through to the owner path (1); owner-match check dropped (2); key status check dropped (3); password-version check dropped (2); service returns the owner's permissions (3).go build ./...,go vet ./...,gofmt -lclean.go test ./internal/api/... ./internal/auth/... ./internal/server/... ./internal/mocks/... -count=1passes, run one package at a time withAWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null. This includes theTestNoUnfailableMockAssertionsguard.--allow-parallel-runners) on./internal/api/... ./internal/auth/... ./internal/server/...: 0 issues.gocyclo -over 10on the changed non-test files (handler_auth.go,types.go,service_apikeys_api.go,app.go): no output.--scan --cachedin a freshgit initrepo with an isolated HOME and the repo'ssetup-git-secrets.sh+.gitallowed: clean.bash scripts/test-git-secrets-allowlist.sh: 42 PASS, 0 FAIL.openapi.yaml: no change./api/auth/me/permissionsis not documented there (audit finding listed it as an undocumented route), and the response shape did not change.auth.Servicecode.Out of scope
GET /api/auth/me(audit A02-024, appended to fix(api/auth): /me/permissions returns owner's full perms instead of API key's effective scope #62): this handler has the opposite problem. It usesrequireSessionPrincipal, so a caller with only a user API key gets 401. When a key and a bearer token are sent together, it returns the owner's id, email andgroups, and the group list reveals the owner's privileges (for example Administrators). Fixing it means deciding what/meshould return for a key: whether to show the owner's groups and how to handle the admin API key, which has no user row. That is a design decision, not a trivial change in this handler family, so it is left out. Note: closing fix(api/auth): /me/permissions returns owner's full perms instead of API key's effective scope #62 will close the issue together with this appended finding; a maintainer may want to split A02-024 into its own issue.PermissionEntrycarries only action/resource for every caller. The key's constraints are computed and returned by the new service method, but not serialized, because adding them would also change the bearer-session response.GetAllowedAccountsAPI) stays per-owner. Scoping by a key'sAccountIDsconstraint applies only where constraint checks run. This PR does not touch it, and there is no/meendpoint for allowed accounts.internal/api/handler_auth.gowas already over the 500-line guideline before this change (668 lines; now 732).Closes #62
🤖 Generated with Claude Code