Skip to content

fix(api/auth): /me/permissions returns owner's full perms instead of API key's effective scope #62

Closed

Description

@cristim

Summary

GET /api/auth/me/permissions (the getCurrentUserPermissions handler) returns the owning user's full permission set when authenticated via a scoped user API key, instead of the key's effective (intersected) permissions. With PR LeanerCloud/cloud-commitments-cli#1226 now making scoped keys usable on the request path, this endpoint becomes a UI-misleading information leak: a view:recommendations-only key gets told it holds admin:* and is_admin: true from the owner.

Repro

  1. User U is an Administrators-group member (holds admin:*).
  2. U creates an API key K scoped to view:recommendations only.
  3. K calls GET /api/auth/me/permissions with x-api-key: <K>.

Today's behavior (post-LeanerCloud/cloud-commitments-cli#1226):

K's actual reachable surface is view:recommendations. The endpoint says otherwise.

Why it matters

  1. Frontend gating: is_admin: true + admin:* is exactly what the SPA reads to decide whether to render the Admin tab, surface user-management actions, expose the rekey UI, etc. A key-holder is shown a UI they cannot operate (every admin action then 403s at requirePermission — which is the correct defense in depth — but the UI lied about the surface).
  2. Trust calibration: a CLI / external integration that consumes this endpoint to decide which calls to attempt will fan out to admin endpoints and absorb a wave of 403s.
  3. Defense-in-depth posture: the rest of PR fix(api): enforce user API key scoped permissions in requirePermission cloud-commitments-cli#1226 carefully intersects key scope with owner perms on the request path. This single endpoint quietly returns the un-intersected set, defeating the abstraction at the most visible advertisement point.

Note: this is not an authorization bypass — requirePermission correctly blocks admin actions for scoped keys (verified by TestRequirePermission_UserAPIKey / regression LeanerCloud/cloud-commitments-cli#1142: scoped key is denied an out-of-scope permission with 403). The issue is information-shape, not access control.

Proposed fix

When resolveAuthenticatedUserID resolves via a user API key, the handler should return the key's effective permissions (intersection), not the user's full set.

Sketch:

  1. Add auth.Service.GetAPIKeyEffectivePermissionsAPI(ctx, apiKey) ([]Permission, error) that wraps ValidateUserAPIKey + ComputeEffectivePermissions.
  2. In getCurrentUserPermissions, branch on the credential type: if an x-api-key matches a user key, return its effective permissions; otherwise current bearer-session path.
  3. is_admin should be derived from the effective set, not the user row.

Acceptance

  • Scoped key K -> /me/permissions returns the SAME permission set the request-path enforcement would honor for K (intersection of key scope and owner group perms).
  • is_admin: true only when the effective set actually contains {admin, *}.
  • Admin API key path unchanged (still returns {admin, *} + is_admin: true).
  • Bearer-session path unchanged.
  • New test: TestGetCurrentUserPermissions_UserAPIKey_ReturnsEffectiveScope.

Surfaced by

Adversarial review of PR LeanerCloud/cloud-commitments-cli#1226 (issue LeanerCloud/cloud-commitments-cli#1142).

Findings from the 2026-09-02 codebase audit

Added by an automated audit of 3c0f8ac94048a2c36fce5ccddee54e6c4849a5cd (tip of origin/main). Each item below was reported by one reviewer and independently confirmed by a second that did not write it. Full report: docs/audits/codebase-audit-2026-09-02.md.

A02-024 (low)

The sibling endpoint has the opposite problem. getCurrentUser (internal/api/handler_auth.go:85-88) calls requireSessionPrincipal, which returns 401 "session authentication required" whenever principal.Session is nil (middleware.go:447-451), so a client authenticating with a user API key cannot read its own id or email at all. getCurrentUserPermissions at :114-121 resolves the same principal through resolveAuthenticatedUserID, and its comment at :107-112 names the API-key case explicitly. Whatever you settle on for the effective-scope semantics here, /api/auth/me should use the same resolver and special-case apiKeyAdminUserID the way the permissions handler does. Finding A02-024.

Activity

  1. cristim commented on Jul 28, 2026

    @cristim
    MemberAuthor

    Reviewed commit: be11bdcb5. Note: origin/main moved to 3e9660d06 during the review; re-verify against current main before changing code, since a finding may have been fixed or moved.

    Re-derived independently during the 2026-07-28 full review of internal/api and confirmed still live on be11bdcb5 (that is, after LeanerCloud/cloud-commitments-cli#1226 landed). Recording as evidence; the issue as written is accurate and the analysis stands.

    Where, on current line numbers: internal/api/handler_auth.go:114-163; resolveAuthenticatedUserID at :172-182.

    One detail worth adding to the fix. The plumbing needed to distinguish the two callers already exists and is simply not read here: Session.UserAPIKeyID (internal/api/types.go:285) was threaded specifically so constraint checks can tell an API-key principal from its owning user, and it is consulted for that purpose at internal/api/handler.go:492-501. getCurrentUserPermissions ignores it and goes straight to GetUserPermissionsAPI(principal.UserID). So the branch condition in the proposed fix does not need a new lookup; session.UserAPIKeyID != nil (or the equivalent principal check) is already available at the handler.

    Failure scenario as re-derived: a CI automation key deliberately scoped to view:recommendations only, owned by an admin, calls /api/auth/me/permissions and receives {"permissions":[{"action":"admin","resource":"*"}],"is_admin":true}. The frontend gates every purchase and approve affordance on this response, so it renders a full-admin surface for a read-only key. Agreeing with this issue's own framing: per-endpoint enforcement still holds, so this is disclosure plus a misleading capability advertisement rather than a direct bypass. It also tells the key's holder exactly which of the owner's privileges are worth trying.

    Related: #61 (unscoped user API key inherits owner's full permissions) is the enforcement-side sibling; this one is the advertisement side. Both should land with tests that assert the same effective set is reported and enforced, so the two cannot drift apart again.

  2. IAmJSD commented on Oct 16, 2026

    @IAmJSD
    Collaborator

    Claiming: working on this

  3. self-assigned this
    on Oct 16, 2026
  4. added a commit that references this issue on Oct 17, 2026
    8c93e39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions