Repository navigation
sec(auth): unscoped API key fails closed instead of inheriting owner permissions - #375
Conversation
|
Warning Review limit reached
This review includes 13 billable files and costs up to $3.25.
Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing. Or wait 33 minutes for your next included review. View limit detailsLimit details: You’ve used all 2 included reviews currently available. Your 56 included PR review attempts over the past 7 days set your current allowance at 2 reviews per hour. Review configuration: ⚙️ Run configurationConfiguration used: Repository: LeanerCloud/cloud-commitments-platform/.coderabbit.yaml Review profile: CHILL Plan: Essentials Run ID: 📒 Files selected for processing (13)
Comment |
|
@coderabbitai review |
|
…ression Summary: CreateAPIKey now rejects an empty permissions array (PR #375's fix for issue #61), but frontend/src/apikeys.ts always called createApiKey(name, undefined, expiresAt) and the create-key modal had no way to select permissions. Every dashboard-created API key would now 400. Fix: - frontend/src/index.html: add a permissions picker container to the create-key modal form, and give the submit button an id so it can be disabled/enabled. - frontend/src/apikeys.ts: showCreateKeyModal fetches the caller's own effective permissions from GET /api/auth/me/permissions on every open and renders one checkbox per entry the owner actually holds (never a wider catalog). The Create button stays disabled until at least one is checked; handleCreateApiKey also re-validates a non-empty selection before calling the API and sends it as `permissions` in the request body. All interpolated values are escaped before the innerHTML write. - internal/auth/service_apikeys_api.go: normalizeAPIKeyPermissions returns []Permission{} instead of nil so a legacy key minted before CreateAPIKey started rejecting empty permissions serializes as "permissions":[] rather than "permissions":null in the create/list API responses (APIKeyInfo.Permissions is no longer omitempty as of the earlier commit in this PR). Regression tests: - frontend/src/__tests__/apikeys.test.ts: fetches and renders the owner's permissions as unchecked checkboxes; labels the admin wildcard entry; escapes a hostile action/resource value (asserts no <img> element is created and the checkbox's dataset round-trips the exact hostile string); shows a message when the owner holds none; shows an inline error when the fetch fails; Create button enables/disables on checkbox change; handleCreateApiKey sends the checked permissions in the request body (asserts a non-empty `permissions` array); rejects submission with an error and never calls the API when nothing is checked; sends every checked permission when more than one is selected. - internal/auth/service_apikeys_api_test.go: a legacy key with Permissions: nil normalizes to an empty (non-nil) slice through ListUserAPIKeysAPI. Verification: - go build ./..., go vet ./... -- clean (GOTOOLCHAIN=go1.26.6) - go test -race -short ./internal/auth/... -- pass (803 subtests) - gocyclo -over 10 -ignore "_test\.go" . -- empty - go mod tidy -diff -- empty - npx tsc --noEmit -- clean - npm test -- 91 suites, 2974 passed, 1 pre-existing skip - npm run lint -- 0 errors (pre-existing warnings only, none in touched files) - npm run build -- succeeds (pre-existing bundle-size warning only)
…permissions
Summary:
An API key created without explicit scopes silently inherited the
owning user's full permission set at request time
(computeEffectivePermissionsFromAuthCtx). "Leave scope blank" was the
most permissive option in the UI, and a long-lived unscoped key became
admin-capable the moment its owner joined the Administrators group.
Root cause:
computeEffectivePermissionsFromAuthCtx returned authCtx.Permissions
(the owner's full set) when the key carried zero permissions, instead
of treating "no scopes granted" as "no permissions granted".
Fix (defense in depth, per the issue's proposed fix):
1. computeEffectivePermissionsFromAuthCtx now returns []Permission{}
for a zero-permission key. An unscoped key still authenticates its
owner but authorizes nothing.
2. CreateAPIKey rejects len(permissions) == 0 with the new
ErrEmptyAPIKeyPermissions sentinel, mapped to 400 in the
createAPIKey handler. Scoping is now a deliberate choice at
creation time, not a default.
3. APIKeyInfo.Permissions in listAPIKeys/create responses is no
longer omitempty, so a legacy zero-scope key (minted before this
fix) is visible in listings instead of looking identical to a
fully-scoped one.
Extracted loadActiveUserForAPIKey out of CreateAPIKey to keep its
cyclomatic complexity under the project's gocyclo gate (10 -> 11 with
the new check -> 8 after the extraction).
Co-Authored-By: claude-flow <ruv@ruv.net>
Adds a real request-path regression test for issue #61, driving a real auth.Service behind a real api.Handler through HandleRequest with an admin-owned, zero-permission API key hitting a protected endpoint. Confirms the request is now denied with 403 rather than succeeding. Verified this fails on the pre-fix service_apikeys.go (the request proceeds past requirePermission and panics on an unmocked store call inside the handler, proving it was not denied) and passes with the fix restored. Co-Authored-By: claude-flow <ruv@ruv.net>
…ression Summary: CreateAPIKey now rejects an empty permissions array (PR #375's fix for issue #61), but frontend/src/apikeys.ts always called createApiKey(name, undefined, expiresAt) and the create-key modal had no way to select permissions. Every dashboard-created API key would now 400. Fix: - frontend/src/index.html: add a permissions picker container to the create-key modal form, and give the submit button an id so it can be disabled/enabled. - frontend/src/apikeys.ts: showCreateKeyModal fetches the caller's own effective permissions from GET /api/auth/me/permissions on every open and renders one checkbox per entry the owner actually holds (never a wider catalog). The Create button stays disabled until at least one is checked; handleCreateApiKey also re-validates a non-empty selection before calling the API and sends it as `permissions` in the request body. All interpolated values are escaped before the innerHTML write. - internal/auth/service_apikeys_api.go: normalizeAPIKeyPermissions returns []Permission{} instead of nil so a legacy key minted before CreateAPIKey started rejecting empty permissions serializes as "permissions":[] rather than "permissions":null in the create/list API responses (APIKeyInfo.Permissions is no longer omitempty as of the earlier commit in this PR). Regression tests: - frontend/src/__tests__/apikeys.test.ts: fetches and renders the owner's permissions as unchecked checkboxes; labels the admin wildcard entry; escapes a hostile action/resource value (asserts no <img> element is created and the checkbox's dataset round-trips the exact hostile string); shows a message when the owner holds none; shows an inline error when the fetch fails; Create button enables/disables on checkbox change; handleCreateApiKey sends the checked permissions in the request body (asserts a non-empty `permissions` array); rejects submission with an error and never calls the API when nothing is checked; sends every checked permission when more than one is selected. - internal/auth/service_apikeys_api_test.go: a legacy key with Permissions: nil normalizes to an empty (non-nil) slice through ListUserAPIKeysAPI. Verification: - go build ./..., go vet ./... -- clean (GOTOOLCHAIN=go1.26.6) - go test -race -short ./internal/auth/... -- pass (803 subtests) - gocyclo -over 10 -ignore "_test\.go" . -- empty - go mod tidy -diff -- empty - npx tsc --noEmit -- clean - npm test -- 91 suites, 2974 passed, 1 pre-existing skip - npm run lint -- 0 errors (pre-existing warnings only, none in touched files) - npm run build -- succeeds (pre-existing bundle-size warning only)
…s set Root cause: after rebasing PR #375 onto main (which merged #392's password/expiry validation for CreateAPIKey), validateCreateAPIKeyRequest now checks permissions before expiresAt. The three expiresAt-focused tests (nil, too-far, in-the-past) still passed []Permission{}, so they now hit ErrEmptyAPIKeyPermissions before ever reaching the expiresAt check they exist to exercise. Give them a real permission so they reach the assertion they're actually testing.
8070813 to
f87a069
Compare
|
Independent adversarial review, 2 passes, final head f87a069: MERGE. Pass 1 approved the unscoped-API-key fix at 8070813. Pass 2 (Opus 5.5, delta after the rebase past #392): #392's guards are intact; the password re-verify still runs first (401) before name, empty-permissions (400), and expiresAt required/past/cap. No main test was dropped; the only changed tests are the intended #61 fail-closed semantics and a required permission fixture on three expiresAt tests. Local on PR+main: build, vet, go test -race -short auth/api/server 3505 pass, golangci-lint v2.10.1 clean, jest apikeys 73/73, eslint+tsc ok; CI 27/27. Optional nit: pin that a wrong password plus empty permissions gives 401. |
Summary
An API key created without explicit scopes silently inherited the owning
user's full permission set at request time. Leaving scope blank was the
most permissive option in the UI, and a long-lived unscoped key became
admin-capable the moment its owner joined the Administrators group.
Root cause
computeEffectivePermissionsFromAuthCtx(internal/auth/service_apikeys.go)returned
authCtx.Permissions(the owner's full set) when the key carriedzero permissions, instead of treating "no scopes granted" as "no permissions
granted".
CreateAPIKeyalso happily accepted a zero-lengthPermissionsslice, so the trap was reachable from key creation, not just from legacy
data.
Fix
Defense in depth, per the issue's proposed fix:
computeEffectivePermissionsFromAuthCtxnow returns[]Permission{}fora zero-permission key. An unscoped key still authenticates its owner but
authorizes nothing (fail closed). Both call sites
(
HasAPIKeyPermissionAPI,HasAPIKeyPermissionForConstraintsAPI) sharethis helper, so both are fixed by the one change.
CreateAPIKeyrejectslen(permissions) == 0with a newErrEmptyAPIKeyPermissionssentinel, mapped to HTTP 400 in thecreateAPIKeyhandler. Scoping is now a deliberate choice at creationtime, not the default.
APIKeyInfo.Permissionsin the create/list API responses is no longeromitempty, so a legacy zero-scope key (minted before this fix) isvisible as
"permissions":[]in listings instead of looking identical toa fully-scoped key -- closing the audit-gap point the issue raised.
openapi.yaml'sCreateAPIKeyRequestnow documentspermissionsasrequired with
minItems: 1.loadActiveUserForAPIKeywas extracted out ofCreateAPIKeyto keep itscyclomatic complexity under the project's
gocyclogate (it was already atthe limit of 10; the new check pushed it to 11; the extraction brings it to
8).
Regression test
internal/server/apikey_unscoped_permission_test.godrives the real stackend to end: a real
auth.Serviceover a mock store, behind a realapi.Handler, throughHandleRequest, with an admin-owned API key thatcarries zero explicit permissions hitting
GET /api/api-keys.internal/auth/service_apikeys.goto its pre-fix content and re-runningthis test. It fails: the unscoped key inherits the admin owner's
{admin, *}wildcard,requirePermissionpasses, and the request proceeds intolistAPIKeys, panicking on an unmocked downstream store call -- proof therequest was never denied.
403.Unit-level regression coverage was also added/updated in
internal/auth/service_apikeys_test.go:TestService_ComputeEffectivePermissions/admin with unscoped key returns no permissions (fail closed)(previously asserted the opposite).TestService_HasAPIKeyPermissionAPI/regression #61: unscoped key is denied (fail closed), not inherited from the owner(previously assertedthe opposite).
TestService_CreateAPIKey/fail when permissions is emptyand/fail when permissions is nil(new, per the issue's acceptance criteria).Fixtures that relied on the old inheritance behavior as an implementation
detail (not the thing under test) were updated to scope the key explicitly:
internal/server/apikey_usage_booking_test.go,internal/server/adapter_test.go,internal/auth/service_apikeys_api_test.go.Verification
go build ./...-- passgo vet ./...-- passgo test -race -short ./...-- 6135 passed, 0 failed, in 33 packages(includes the full suite, not just the touched packages)
go mod tidy -diff-- emptygofmt -lon all touched files -- emptygocyclo -over 10 -ignore "_test\.go" .-- empty (repo-wide, matches theCI gate exactly)
golangci-lint(exact CI-pinnedv2.10.1,~/go/bin/golangci-lint):could not run locally -- it panics with
file requires newer Go version go1.27 (application built with go1.26)while type-checking this module.Confirmed this is a pre-existing local toolchain/version mismatch, not
caused by this change: reproduced the identical panic on an unmodified
checkout of
origin/mainbefore making any edits. Relying on CI'sgolangci-lint-action-managed environment (which pins its own Gotoolchain) for lint verification.
unavailable in this environment); no DB-touching code changed in this PR,
so CI's container suite should be unaffected either way.
Notes
finding tracked in a sibling issue (sec(auth): unscoped API key is a permanent MFA-bypassing clone of its owner that survives password reset #102); that issue's fix builds on top
of this one rather than duplicating it.
Update: frontend fix-first review round
Independent review of this PR found that the backend fix was correct but
incomplete: nothing in the dashboard could mint an API key anymore once
CreateAPIKeystarted rejecting an emptypermissionsarray.frontend/src/apikeys.tsalways calledcreateApiKey(name, undefined, expiresAt), and the create-key modal (frontend/src/index.html) had noway to select permissions -- so every dashboard-created key would now 400.
Two changes address this:
showCreateKeyModalnow fetches thecaller's own effective permissions from
GET /api/auth/me/permissionson every open and renders one checkbox per entry the owner actually
holds -- never a wider catalog, so a key can never be scoped beyond
what its creator holds. The Create button stays disabled until at
least one permission is checked;
handleCreateApiKeyre-validates anon-empty selection before calling the API (belt-and-suspenders
alongside the disabled button) and sends it as
permissionsin therequest body. Every interpolated action/resource value is escaped
before the
innerHTMLwrite.CreateAPIKeystarted rejecting empty permissions can still have a
NULLpermissions column, which the JSONB scan surfaces as a nil slice.
Since
APIKeyInfo.Permissionsis no longeromitempty(this PR'soriginal fix), a raw nil would serialize as
"permissions":nullinstead of
"permissions":[].normalizeAPIKeyPermissionsininternal/auth/service_apikeys_api.gocloses that gap in both thecreate and list API mappings.
New/updated tests:
frontend/src/__tests__/apikeys.test.tscoversfetching and rendering the picker, labeling the admin wildcard entry,
escaping a hostile action/resource value (asserts no
<img>element iscreated and the checkbox's
datasetround-trips the exact hostilestring), the empty-permissions and fetch-failure states, the Create
button's enable/disable wiring, and
handleCreateApiKeysending anon-empty
permissionsarray (plus rejecting submission when nothing ischecked, and sending every checked entry when more than one is selected).
internal/auth/service_apikeys_api_test.goadds a regression asserting alegacy nil-permissions key normalizes to an empty, non-nil slice through
ListUserAPIKeysAPI.Verification for this round:
go build ./.../go vet ./...clean(
GOTOOLCHAIN=go1.26.6);go test -race -short ./internal/auth/...passes;
gocyclo -over 10 -ignore "_test\.go" .empty;go mod tidy -diffempty;npx tsc --noEmitclean;npm test-- 91 suites, 2974passed, 1 pre-existing skip;
npm run lint-- 0 errors (onlypre-existing warnings, none in touched files);
npm run buildsucceeds.Closes #61