Skip to content

Commit d93d9be

Browse files
qa(authz-conformance), docs(api): record that POST /api/v1/keys mints only for a signed-in session (#27718)
Fixes #27708 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:授权一致性矩阵新增一行 `keys-mint-session-required`,记录「只有登录会话才能创建 API key」;两处文档各加一句说明这条规则。 - 为什么改:#27536 已上线该规则(用 API key 调 `POST /api/v1/keys` 得到 403 `PERMISSION_DENIED`),但矩阵和文档都没写,脚本作者只能从 403 才发现。 - 风险与回滚:零运行时改动,只动一个私有测试包的数据文件和两页文档;回滚即 revert 本 PR。 - 先看:`packages/qa/dogfood/test/authz-conformance.matrix.ts` 新行的 `enforcement`(对照 `runtime/domains/keys.ts` 第 146 行);`content/docs/api/index.mdx` 的 API key 条目;`content/docs/ai/connect-mcp.mdx` 的 "Or mint one over REST" 一句。 - 两分钟验证:`pnpm --filter @objectstack/dogfood exec vitest run test/authz-conformance.test.ts test/authz-probe-blind-spot.test.ts`,预期 2 个文件、70 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes - `packages/qa/dogfood/test/authz-conformance.matrix.ts` - New row `keys-mint-session-required` beside `anonymous-deny-keys`, `state: 'enforced'`. Its `enforcement` is read from `packages/runtime/src/domains/keys.ts`: the domain-wide positive check `context.admission?.credential === 'session'` (L146), directly below the anonymous decision (L129) and ahead of the body read and the method check. Every other admission (an API key in any of its three header spellings, or no recorded admission) answers 403 `PERMISSION_DENIED`. The `note` names the pin file `runtime/domains/keys-mint-interactive-session.pin.test.ts` (14 cases). - The row carries no `covers` key. This is a predicate inside a domain that `anonymous-deny-keys` already classifies, so it adds no entry point for `discover()`. A `covers` key that no probe mints fails as STALE. This is the matrix's in-resolver convention, and the row's `note` says so. - `anonymous-deny-keys`: "a signed-in caller reaches the mint exactly as before" now reads "a session-admitted caller ... (a caller admitted by an API key is refused by the next row, `keys-mint-session-required`)", so the two rows agree. - Docblock covers census: "49 of 67 ... 43 of the file's 60 `enforced` rows" now reads "51 of 69 ... 45 of 62". See the Acceptance notes. - `content/docs/api/index.mdx` (the **API key** bullet): one sentence saying that minting needs a signed-in session, a request authenticated with an API key is refused `403 PERMISSION_DENIED`, and so a key cannot mint another key. "Send it as" becomes "Send the key as" so the pronoun stays unambiguous. - `content/docs/ai/connect-mcp.mdx` (under "Or mint one over REST"): the lead-in now names the signed-in session (the `-b cookies.txt` in the snippet) and the 403 for an API-key request. Open PR #27649 edits this file only at its L242 and L392 hunks, so the two do not overlap. No runtime change. `packages/qa/dogfood` is `private: true` and `content/docs` does not publish, so no changeset is owed and the PR carries `skip-changeset`. ## Tests Every reader of the matrix that imports or reads its text was run. `authz-conformance.test.ts` imports `AUTHZ_CONFORMANCE`. `authz-probe-blind-spot.test.ts` reads the matrix text. The other files that grep the name only mention it in comments. <details><summary>Matrix readers: 2 files, 70 tests, pass (head 2ba6263)</summary> ``` pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/authz-conformance.test.ts test/authz-probe-blind-spot.test.ts Test Files 2 passed (2) Tests 70 passed (70) os-verify-lock: VERDICT command-exit 0 ``` </details> <details><summary>The pin the new row cites: 14 tests, pass</summary> ``` pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 src/domains/keys-mint-interactive-session.pin.test.ts Test Files 1 passed (1) Tests 14 passed (14) ``` </details> <details><summary>Dogfood typecheck: exit 0, and the program does include the matrix file</summary> `pnpm --filter @objectstack/dogfood run typecheck` exits 0. With `--listFiles` the program holds 409 files under `packages/qa/dogfood/test/`, one of them `authz-conformance.matrix.ts`. </details> ## Acceptance notes - The docblock's covers census was already stale at base `2140821ea6`. It said 67 rows and 60 `enforced`, but a count at base read 68 rows, 50 with no `covers`, 61 `enforced` and 44 `enforced` with no `covers`. The new row moves each figure by one more, so they were corrected to the measured values: 69, 51, 62 and 45. No test pins these figures. The #17111 pin reads only the two ledger group counts, which this PR does not move. - No ablation was run. The diff changes data rows and prose, not a behaviour that a test pins. ## Verification Gate families come from `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at head `2ba6263244` (3 paths). Each exit code was recorded before any pipe and reconciled with `--ran`. <details><summary>Local derived set: 71 derived, 70 run exit 0, 1 NOT MEASURED</summary> - `--ran` verdict: `71 derived famil(ies) accounted for — 70 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)`. - NOT MEASURED: `pnpm check:dual-build-cjs-loads` exits 3 with PREREQUISITE NOT MET, because six packages outside this diff's build closure have no `dist/` (studio, embedder-openai, knowledge-memory, knowledge-ragflow, service-cluster-redis, service-knowledge). CI's `Lint & Repo Gates` runs it on a full build. - Build closure: `pnpm --filter '@objectstack/dogfood^...' build` exits 0. `@objectstack/client-react...` was also built, because `check:skill-examples` reads its `dist`. That gate exited 3 before the build and 0 after it. - `--ran` flagged the tree as 11 commits behind origin/main. The one derivation input that changed in that range is `scripts/pm/check-skill-line-ratchet.mjs`, which none of the 71 derived families runs. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QSeKYmTYfaPbLQD4BJfomW)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent eac6d96 commit d93d9be

3 files changed

Lines changed: 11 additions & 5 deletions

File tree

‎content/docs/ai/connect-mcp.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,8 @@ Your own keys are listed (prefix only) and revoked under **Account → Developer
118118
→ API Keys**. Platform admins get the tenant-wide list at **Setup → Access
119119
Control → API Keys**, which requires `manage_platform_settings`.
120120

121-
Or mint one over REST:
121+
Or mint one over REST, from a signed-in session (the `-b cookies.txt` below). A
122+
request authenticated with an API key is refused `403 PERMISSION_DENIED`:
122123

123124
```bash
124125
curl -b cookies.txt -X POST https://your-deployment.example.com/api/v1/keys

‎content/docs/api/index.mdx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,9 @@ permission model grants anonymous users. Two ways to authenticate:
6868
- **API key** (scripts, CI, headless agents): mint one with `POST /api/v1/keys`
6969
(the key is shown once), or from the **Connect an Agent** page in the Console
7070
— **Account → Developer** for any signed-in user, **Setup → Connect an Agent**
71-
for platform admins. Send it as `x-api-key: osk_…` or
71+
for platform admins. Minting needs a signed-in session: a request
72+
authenticated with an API key is refused `403 PERMISSION_DENIED`, so a key
73+
cannot mint another key. Send the key as `x-api-key: osk_…` or
7274
`Authorization: Bearer osk_…`. Keys expire (90 days by default, at most a
7375
year) and stop working after 90 days without use; a refused key answers
7476
`403` with `API_KEY_EXPIRED`, `API_KEY_INACTIVE` or `API_KEY_REVOKED`, and the

‎packages/qa/dogfood/test/authz-conformance.matrix.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,8 @@
109109
// [commit 2ce1eb41b] That completeness is over ROUTES, not over primitives: a primitive
110110
// enforced by a predicate inside an existing resolver adds no entry point, so
111111
// it can be neither UNCLASSIFIED nor STALE. Measured against the rows below:
112-
// 49 of 67 carry no `covers` key at all (18 rows, 33 keys, every one an
113-
// HTTP/transport pin), and 43 of the file's 60 `enforced` rows carry none:
112+
// 51 of 69 carry no `covers` key at all (18 rows, 33 keys, every one an
113+
// HTTP/transport pin), and 45 of the file's 62 `enforced` rows carry none:
114114
// the in-resolver shape — the ADR-0049/#8613 `active` rows and the ADR-0091
115115
// grant-validity-window row among them (see their own blocks further down)
116116
// are the normal case, not an exception — plus five ADR-0138 D2 door-class
@@ -334,9 +334,12 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [
334334
covers: ['notifications:domains/notifications.ts:anonymous-gate', 'dispatcher-domain:route-ledger.ts:/notifications'],
335335
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.' },
336336
{ id: 'anonymous-deny-keys', summary: 'anonymous-deny on the API-key mint surface', state: 'enforced',
337-
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',
337+
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`)',
338338
covers: ['keys:domains/keys.ts:anonymous-gate', 'dispatcher-domain:route-ledger.ts:/keys'],
339339
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.' },
340+
{ 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',
341+
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',
342+
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).' },
340343
{ 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',
341344
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',
342345
covers: ['share-links:domains/share-links.ts:anonymous-gate', 'dispatcher-domain:route-ledger.ts:/share-links'],

0 commit comments

Comments
 (0)