From 2ba6263244372bca4dc93a876afee10da0d7a308 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 20:05:05 +0000 Subject: [PATCH] qa(authz-conformance), docs(api): record that POST /api/v1/keys mints only for a signed-in session Adds the `keys-mint-session-required` row to the authz-conformance matrix beside `anonymous-deny-keys` (enforcement read from runtime/domains/keys.ts, note naming the 14-case pin), aligns `anonymous-deny-keys` to say a session-admitted caller reaches the mint, refreshes the docblock's covers census figures, and adds one sentence naming the rule to the API-key bullet in content/docs/api/index.mdx and under "Or mint one over REST" in content/docs/ai/connect-mcp.mdx. No runtime change. Claude-Session: https://claude.ai/code/session_01QSeKYmTYfaPbLQD4BJfomW Co-authored-by: Claude --- content/docs/ai/connect-mcp.mdx | 3 ++- content/docs/api/index.mdx | 4 +++- packages/qa/dogfood/test/authz-conformance.matrix.ts | 9 ++++++--- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/content/docs/ai/connect-mcp.mdx b/content/docs/ai/connect-mcp.mdx index 78ff6091e62..92500b617b4 100644 --- a/content/docs/ai/connect-mcp.mdx +++ b/content/docs/ai/connect-mcp.mdx @@ -118,7 +118,8 @@ Your own keys are listed (prefix only) and revoked under **Account → Developer → API Keys**. Platform admins get the tenant-wide list at **Setup → Access Control → API Keys**, which requires `manage_platform_settings`. -Or mint one over REST: +Or mint one over REST, from a signed-in session (the `-b cookies.txt` below). A +request authenticated with an API key is refused `403 PERMISSION_DENIED`: ```bash curl -b cookies.txt -X POST https://your-deployment.example.com/api/v1/keys diff --git a/content/docs/api/index.mdx b/content/docs/api/index.mdx index 1dfed5290f4..84831f29249 100644 --- a/content/docs/api/index.mdx +++ b/content/docs/api/index.mdx @@ -68,7 +68,9 @@ permission model grants anonymous users. Two ways to authenticate: - **API key** (scripts, CI, headless agents): mint one with `POST /api/v1/keys` (the key is shown once), or from the **Connect an Agent** page in the Console — **Account → Developer** for any signed-in user, **Setup → Connect an Agent** - for platform admins. Send it as `x-api-key: osk_…` or + for platform admins. Minting needs a signed-in session: a request + authenticated with an API key is refused `403 PERMISSION_DENIED`, so a key + cannot mint another key. Send the key as `x-api-key: osk_…` or `Authorization: Bearer osk_…`. Keys expire (90 days by default, at most a year) and stop working after 90 days without use; a refused key answers `403` with `API_KEY_EXPIRED`, `API_KEY_INACTIVE` or `API_KEY_REVOKED`, and the diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts index b8faefcb3bc..eac70685660 100644 --- a/packages/qa/dogfood/test/authz-conformance.matrix.ts +++ b/packages/qa/dogfood/test/authz-conformance.matrix.ts @@ -109,8 +109,8 @@ // [commit 2ce1eb41b] That completeness is over ROUTES, not over primitives: a primitive // enforced by a predicate inside an existing resolver adds no entry point, so // it can be neither UNCLASSIFIED nor STALE. Measured against the rows below: -// 49 of 67 carry no `covers` key at all (18 rows, 33 keys, every one an -// HTTP/transport pin), and 43 of the file's 60 `enforced` rows carry none: +// 51 of 69 carry no `covers` key at all (18 rows, 33 keys, every one an +// HTTP/transport pin), and 45 of the file's 62 `enforced` rows carry none: // the in-resolver shape — the ADR-0049/#8613 `active` rows and the ADR-0091 // grant-validity-window row among them (see their own blocks further down) // are the normal case, not an exception — plus five ADR-0138 D2 door-class @@ -334,9 +334,12 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ covers: ['notifications:domains/notifications.ts:anonymous-gate', 'dispatcher-domain:route-ledger.ts:/notifications'], note: 'Before the move the decision was the domain\'s own user-id check, written below the capability probe, so a deployment without an inbox answered an anonymous caller 501 while one with an inbox answered 401 — the booted showcase carries an inbox, which is why the sweep read 401 there. Gating the DOMAIN rather than each face keeps a newly added face from arriving ungated. Unit-proven in runtime/domains/domain-floor-anonymous-deny.test.ts (every face and an unserved path refused 401 before any service is resolved, both anonymous shapes the dispatcher produces, an empty slot still 401, the signed-in control unchanged); on the booted showcase the mounted faces are held to 401 by the anonymous door sweep\'s everything-else class.' }, { id: 'anonymous-deny-keys', summary: 'anonymous-deny on the API-key mint surface', state: 'enforced', - enforcement: 'runtime/domains/keys.ts handleKeysRequest — shouldDenyAnonymous DOMAIN-WIDE as the handler\'s FIRST statement, ahead of the method check (so an anonymous caller is never answered the 405 describing the route\'s shape) and ahead of the body whitelist and the data-service probe; every verb answers the dispatcher-wrapper 401 UNAUTHENTICATED, and a signed-in caller reaches the mint exactly as before', + enforcement: 'runtime/domains/keys.ts handleKeysRequest — shouldDenyAnonymous DOMAIN-WIDE as the handler\'s FIRST statement, ahead of the method check (so an anonymous caller is never answered the 405 describing the route\'s shape) and ahead of the body whitelist and the data-service probe; every verb answers the dispatcher-wrapper 401 UNAUTHENTICATED, and a session-admitted caller reaches the mint exactly as before (a caller admitted by an API key is refused by the next row, `keys-mint-session-required`)', covers: ['keys:domains/keys.ts:anonymous-gate', 'dispatcher-domain:route-ledger.ts:/keys'], note: 'Before the move the decision was the domain\'s own user-id check, written below the method check, so an anonymous caller using any verb but POST was answered 405. The key is pinned to the caller and never read from the body; that rule is unchanged and runs after this floor. Unit-proven in runtime/domains/domain-floor-anonymous-deny.test.ts (the mint face and every unserved verb refused 401 before any service is resolved; a member still gets the 405 for a wrong verb and reaches the data service on POST); on the booted showcase the mounted face is held to 401 by the anonymous door sweep\'s everything-else class.' }, + { id: 'keys-mint-session-required', summary: 'the API-key mint surface admits only a signed-in session (an API-key-admitted caller cannot mint a key)', state: 'enforced', + enforcement: 'runtime/domains/keys.ts handleKeysRequest — DOMAIN-WIDE, directly below the anonymous decision (`anonymous-deny-keys`) and ahead of the body read and the method check, the handler admits only `context.admission?.credential === \'session\'` (the credential resolveAuthzContext decided, handed to the request by the dispatcher\'s identity step); every other admission — an API key in any of its three header spellings, or no recorded admission at all — answers 403 PERMISSION_DENIED with nothing read and nothing written', + note: 'Positive on purpose: the check names the one credential that may mint, so a credential the door was never told about is refused rather than admitted. A session cookie sent beside a key does not lift the refusal, because the key decides the admission. No `covers` key: this is a predicate inside a domain the `anonymous-deny-keys` row already classifies, so it adds no entry point for `discover()` to see. Unit-proven in runtime/domains/keys-mint-interactive-session.pin.test.ts (through dispatch: the session-admitted mint lands one row pinned to the session user; each API-key header spelling, a key beside a session cookie, and every other verb under a key answer 403 PERMISSION_DENIED with nothing written; a session-admitted wrong verb still answers 405 and an anonymous caller still 401. Through the handler: no admission and an `api_key` admission are refused before the body is read or any service is resolved, and a session admission mints).' }, { id: 'anonymous-deny-share-links', summary: 'anonymous-deny on the share-link management surface of the dispatcher (the two token faces are ADR-0138 D2 class 2)', state: 'enforced', enforcement: 'runtime/domains/share-links.ts handleShareLinksRequest — shouldDenyAnonymous DOMAIN-WIDE as the domain\'s first decision, ahead of the share-link service probe and every face, with the ONE exception ADR-0138 D2 names for this domain: the two token faces (GET /:token/resolve, GET /:token/messages), selected by the same isPublicShareLinkRoute predicate that marks them public, which the capability token admits (class 2). Every other face, and every path or verb the domain does not serve, answers the dispatcher-wrapper 401 UNAUTHENTICATED', covers: ['share-links:domains/share-links.ts:anonymous-gate', 'dispatcher-domain:route-ledger.ts:/share-links'],