Skip to content

Commit fd0e72a

Browse files
docs(qa): re-point identity-auth.api-key-ui-lifecycle's revoke oracle to 403 API_KEY_REVOKED (#24012)
Fixes #23992 Clause-②: no The platform-checklist item `identity-auth.api-key-ui-lifecycle` expected a revoked API key's next `x-api-key` call to answer `401`. Since the API-key lifecycle (#23367 ruling A, executed by #23541), a recognised but revoked key is refused `403` with `error.code` `API_KEY_REVOKED`. Only an unknown key still gets the generic `401`. So the item scored a correct runtime red. This PR re-points step 4 and acceptance clause 2 to the shape that shipped, bumps `revision` 1 → 2, and appends a `history` row naming #23992, #23367 and #23541. The diff is `docs/qa/platform-checklist/areas/identity-auth.json` only (+10 / −8, one entry). There is no product code change. The clause is not loosened. A revoked key that answers `200` or serves any row still fails it. So does a post-revoke refusal that is the generic `401`, or a `403` carrying any other code. Step 5 (restore → `200`) is unchanged. The oracle scores the status, the code and the absence of rows. It pins none of the refusal message's wording, because the lifecycle owner rewords that text (#23944, PR #23991). This matches what PR #24000 did for the MCP revocation clauses. ## Old and new text **Step 4** (`steps[4]`) - Old: `revoke the key via the row action revoke_api_key (PATCH /api/v1/data/sys_api_key/{id} bodyExtra {revoked:true}); immediately re-call the data route with x-api-key and capture the 401` - New: `revoke the key via the row action revoke_api_key (PATCH /api/v1/data/sys_api_key/{id} bodyExtra {revoked:true}); immediately re-call the data route with x-api-key and capture the 403 whose body's error.code is API_KEY_REVOKED and which carries no rows (a recognised key its own lifecycle refuses — only an UNKNOWN key gets the generic 401)` **Acceptance clause 2** (`acceptance[2]`) - Old clause: `revoke is immediate and enforced server-side: after revoke_api_key the same x-api-key call 401s on the very next request` - Old verify: `the post-revoke x-api-key request returns 401` - Old evidence: `the revoke PATCH response + the 401` - New clause: `revoke is immediate and enforced server-side: after revoke_api_key the same x-api-key call is refused on the very next request with the key's own lifecycle refusal — 403, error.code API_KEY_REVOKED, and no rows. Since #23367 (executed by #23541) a recognised but revoked key is refused 403 API_KEY_REVOKED; the generic 401 answers only a key the server does not recognise at all. The refusal's message is the key's own lifecycle message and its wording is NOT pinned here: the code is what a client branches on, and the lifecycle owner rewords that text (#23944)` - New verify: `the post-revoke x-api-key request returns HTTP 403 and its body's error.code is API_KEY_REVOKED, with no record rows in the body (packages/core/src/security/api-key.ts#lifecycleRefusalOf decides it; the data routes answer it through packages/rest/src/rest-server.ts#enforceAuth). Score the status, the code and the absence of rows, never the message's wording. A 200 or any served row, a generic 401, or a 403 carrying any other code is a FAIL — the last means the key was accepted and something later refused` - New evidence: `the revoke PATCH response + the 403 API_KEY_REVOKED refusal body` **Also in the same entry** - `negative[1]` keeps its text and adds the other failure direction: a post-revoke refusal that is the generic `401`, or carries any code other than `API_KEY_REVOKED`, is a FAIL. - `source` gains one row: `packages/core/src/security/api-key.ts#lifecycleRefusalOf` + `packages/rest/src/rest-server.ts#enforceAuth`. Both anchors resolve under `check-platform-checklist`. - `history` gains the `revision: 2` row (`ref: #23992`). ## The source lines (at `origin/main` `e18b23fac`) - `packages/core/src/security/api-key.ts:217`: `const API_KEY_REVOKED_REFUSAL = { code: 'API_KEY_REVOKED', status: 403 } as const;` - `packages/core/src/security/api-key.ts:299` starts `lifecycleRefusalOf`. Its first arm (lines 306-321), `if (row.revoked === true || row.revoked === 1)`, returns `code: API_KEY_REVOKED_REFUSAL.code` (line 308) with the located message. `packages/core/src/security/resolve-authz-context.ts:501` raises it as the branded `ApiKeyLifecycleRefusalError`, whose status is `API_KEY_LIFECYCLE_REFUSAL_STATUS` = 403 (`api-key.ts:228`). - `packages/rest/src/rest-server.ts:3493-3495`: the context resolver remembers the branded refusal beside the request. Lines 2478-2481: `enforceAuth` answers `res.status(lifecycle.status).json({ error: { code: lifecycle.code, message: lifecycle.message } })`. A request with no remembered refusal falls through to the generic anonymous `401`. - Permanent pin of that relay: `packages/rest/src/rest-api-key-lifecycle.pin.test.ts`, "revoked, expired and inactive answer 403 with three distinct codes". ## Re-run record: #24011 `QA run · identity-auth.api-key-ui-lifecycle (1/1) · e1e711a · 2026-10-16 · 1 PASS / 0 PARTIAL / 0 FAIL / 0 BLOCKED / 0 NOT-RUN`. `node scripts/qa/qa-rollup.mjs` parses the title (row `#24011`, `1/1`, sha `e1e711ae`). The run used a real showcase boot built from this branch's tree (`e1e711ae2`; product tree `origin/main` `e18b23fac`). `pnpm build` ran 75/75 tasks. The console was built at the `.objectui-sha` pin `f8267df93aba` with `pnpm objectui:build`, and `check:console-sha` was green. The server ran on its own port (41873) and its own file DB, and was stopped by PID afterwards. Personas were the seeded admin and members minted by invite + sign-up. The browser legs were driven through Playwright (Chromium 141). | # | clause | verdict | evidence (short) | |---|---|---|---| | 0 | secret shown once | pass | mint 201 with a raw `key`; get-by-id (owner and admin) and the mine list carry no `key` field, only `prefix`; the dialog showed the raw value, and after Done it is gone from the page | | 1 | key = owner's view | pass | `showcase_task` via `x-api-key` 200, 10 rows, the same id set as the owner's session. On `showcase_inquiry` the admin sees 3 rows, the owner's session 0 and the owner's key 0 | | 2 | revoke → 403 `API_KEY_REVOKED` | pass | PATCH `{"revoked": true}` 200. The very next read answered 403 with body `{"error": {"code": "API_KEY_REVOKED", "message": …}}` and no rows, and a repeat answered the same. Through the console's Revoke API Key row action, the request body was `{"revoked":true}` and the next two reads answered 403 `API_KEY_REVOKED`. Control: an unknown key answers 401 `UNAUTHENTICATED` | | 3 | restore → 200 | pass | PATCH `{"revoked": false}` 200, then a read answered 200 with 10 rows. The console's Restore API Key row action gave the same result | | 4 | mine-view scoping | pass | The second member's mine list and unfiltered list both exclude the owner's key, and get-by-id answers 404. With two owners, each one's mine list shows only its own key. The admin's all_keys list (API and Setup → API Keys) shows every owner's key | | 5 | dialog + list screenshots | pass | The dialog shows "not be shown again", the secret and Copy. The re-opened My Keys list shows name + prefix rows only | ## Gates (at `e1e711ae2`, the final commit) - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 13 commands. Each was run with its exit code captured before any pipe, and all 13 exited 0: `check-ci-filter-parity`, `check-closing-keyword-parity` (+ `--self-test`), `check-comment-mask-corpus`, `@objectstack/lint check:doc-formula-expressions`, `check:cross-package-test-inputs`, `check:doc-authoring`, `check:driver-memory-census`, `check:gitlink-declared`, `check:nul-bytes`, `check:platform-checklist` ("OK — 16 areas, 327 items …; symbol anchors: 834/844 resolved …"), `check:refd-timer-probe`, `check:watch-hint-literal`. - `--ran` reconciliation: "13 derived famil(ies) accounted for — 13 run, 0 NOT-MEASURED (a DERIVED zero — all 13 recorded an exit code and none of them is 3)". - `node scripts/pm/check-governed-merges.mjs --branch claude/issue-23992-api-key-ui-revoke-oracle`: "0 of 1 path(s) hit the register … NOT governed"; 18 changed lines. - This is a ledger-only change and publishes nothing, so there is no changeset. ## Acceptance notes - PR #23991 (#23944) landed before this branch was cut. The card suggested also checking that the message names "Restore API Key". This PR does not do that, because the dispatch ruling says the oracle never scores the prose. The live message does name the Restore API Key action, but that was observed, not scored. - Two dogfood files, `api-key-revoke-lifecycle` and `api-key-owner-revoke`, accept a generic `401` as well as `403 API_KEY_REVOKED` for a revoked key. The strict `403` + code relay is pinned only at unit level (`packages/rest/src/rest-api-key-lifecycle.pin.test.ts`), and this item has no `automated.ref`. Carrier: none. - The item's `source` row for `sys-api-key.object.ts` still says `apiMethods ['get','list']`, but the object declares `['get', 'list', 'update']`. This is a stale annotation that scores nothing, so it is outside this card's class and was left untouched. Carrier: none. - Scope: `negative[1]` and `source[]` are inside the claimed entry but outside the claim's parenthetical field list. Both carry the ruling's "do not loosen" rule and the citation it asks for. --- _Generated by [Claude Code](https://claude.ai/code/session_016PVjjuVPte5XEJvaQx1E4t)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent fd9664f commit fd0e72a

1 file changed

Lines changed: 10 additions & 8 deletions

File tree

‎docs/qa/platform-checklist/areas/identity-auth.json‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -604,7 +604,7 @@
604604
"title": "Personal API key: minted show-once on Connect-an-Agent, authenticates as its owner, revoke kills it and restore brings it back — mine-view scoped",
605605
"since": "v16",
606606
"status": "active",
607-
"revision": 1,
607+
"revision": 2,
608608
"priority": "P1",
609609
"surface": "mixed",
610610
"personas": ["a signed-in member (key owner)", "platform admin", "the key itself (machine caller via x-api-key)"],
@@ -620,7 +620,7 @@
620620
"capture the mint response: POST /api/v1/keys returns data.key (the raw secret) exactly once, plus id / name / prefix",
621621
"record the raw secret, close the dialog, then re-open the API Keys list (sys_api_key `mine` view) and confirm the row shows only the prefix — the raw key is never shown again (the stored `key` column is hashed + hidden)",
622622
"call a data route as the machine caller: GET /api/v1/data/showcase_task with header x-api-key: <raw secret> (NOT a session cookie/Bearer) and capture the 200 + that the rows returned are the OWNER's row-level-security view (the key acts as the user)",
623-
"revoke the key via the row action revoke_api_key (PATCH /api/v1/data/sys_api_key/{id} bodyExtra {revoked:true}); immediately re-call the data route with x-api-key and capture the 401",
623+
"revoke the key via the row action revoke_api_key (PATCH /api/v1/data/sys_api_key/{id} bodyExtra {revoked:true}); immediately re-call the data route with x-api-key and capture the 403 whose body's error.code is API_KEY_REVOKED and which carries no rows (a recognised key its own lifecycle refuses — only an UNKNOWN key gets the generic 401)",
624624
"restore via restore_api_key (PATCH … {revoked:false}); re-call and capture the 200 again — the lifecycle is reversible",
625625
"mine-view scoping: sign in as a DIFFERENT member and confirm the sys_api_key `mine` view (filter user_id={current_user_id}) does NOT list the first user's key; then as admin open Setup → API Keys (the `all_keys` view, nav gated on manage_platform_settings) and confirm every user's key is listed",
626626
"attempt to read the raw secret back via the data API (GET /api/v1/data/sys_api_key/{id}) and confirm the `key` field is absent/hidden — only the prefix is ever returned"
@@ -639,10 +639,10 @@
639639
"evidence": "the x-api-key request trace + a session-read comparison"
640640
},
641641
{
642-
"clause": "revoke is immediate and enforced server-side: after revoke_api_key the same x-api-key call 401s on the very next request",
642+
"clause": "revoke is immediate and enforced server-side: after revoke_api_key the same x-api-key call is refused on the very next request with the key's own lifecycle refusal — 403, error.code API_KEY_REVOKED, and no rows. Since #23367 (executed by #23541) a recognised but revoked key is refused 403 API_KEY_REVOKED; the generic 401 answers only a key the server does not recognise at all. The refusal's message is the key's own lifecycle message and its wording is NOT pinned here: the code is what a client branches on, and the lifecycle owner rewords that text (#23944)",
643643
"oracle": "api",
644-
"verify": "the post-revoke x-api-key request returns 401",
645-
"evidence": "the revoke PATCH response + the 401"
644+
"verify": "the post-revoke x-api-key request returns HTTP 403 and its body's error.code is API_KEY_REVOKED, with no record rows in the body (packages/core/src/security/api-key.ts#lifecycleRefusalOf decides it; the data routes answer it through packages/rest/src/rest-server.ts#enforceAuth). Score the status, the code and the absence of rows, never the message's wording. A 200 or any served row, a generic 401, or a 403 carrying any other code is a FAIL — the last means the key was accepted and something later refused",
645+
"evidence": "the revoke PATCH response + the 403 API_KEY_REVOKED refusal body"
646646
},
647647
{
648648
"clause": "restore is reversible: restore_api_key flips revoked back to false and the key authenticates again (200)",
@@ -665,7 +665,7 @@
665665
],
666666
"negative": [
667667
"the raw secret being readable a second time (in a list row, a get-by-id, or a re-opened dialog) is a FAIL — show-once means the hash is all the server keeps",
668-
"a revoked key still returning 200 on a data route is a FAIL — revocation must be enforced at auth, not only hidden in the list",
668+
"a revoked key still returning 200 on a data route is a FAIL — revocation must be enforced at auth, not only hidden in the list; so is a post-revoke refusal that is the generic 401 or carries any code other than API_KEY_REVOKED (#23367: a client branches on the code, never on the prose)",
669669
"an api key that reads MORE than its owner's RLS view (a superuser bypass) is a FAIL — the key carries the user's permissions",
670670
"one user's key appearing in another user's mine view is an RLS FAIL"
671671
],
@@ -674,10 +674,12 @@
674674
"objectui apps/console/src/pages/developer/AgentConnectSection.tsx (ADR-0036 Phase 2b: POST /api/v1/keys mint, show-once dialog, x-api-key connect steps)",
675675
"packages/runtime/src/route-ledger.ts (POST /keys → client keys.create)",
676676
"packages/platform-objects/src/identity/sys-api-key.object.ts#bodyExtra (revoke_api_key/restore_api_key actions PATCH /api/v1/data/sys_api_key/{id} bodyExtra revoked; mine view user_id={current_user_id} vs all_keys; hashed hidden `key`, visible `prefix`; apiMethods ['get','list'])",
677-
"packages/platform-objects/src/apps/setup-nav.contributions.ts#nav_api_keys (nav_api_keys requiredPermissions ['manage_platform_settings'] — admin all-view)"
677+
"packages/platform-objects/src/apps/setup-nav.contributions.ts#nav_api_keys (nav_api_keys requiredPermissions ['manage_platform_settings'] — admin all-view)",
678+
"packages/core/src/security/api-key.ts#lifecycleRefusalOf (#23367 / #23541 — a recognised revoked key is refused 403 API_KEY_REVOKED with a located message; an unknown key keeps the generic 401) + packages/rest/src/rest-server.ts#enforceAuth (the data routes answer the remembered lifecycle refusal's status and code)"
678679
],
679680
"history": [
680-
{ "revision": 1, "date": "2026-08-08", "change": "new item: personal API key mint (show-once, POST /api/v1/keys) → authenticate-as-owner via x-api-key → revoke 401 → restore 200, plus mine-view vs admin all-view scoping, grounded in AgentConnectSection + sys_api_key object (PENDING-GAPS §B)", "ref": "claude/platform-test-checklist-ocwugl" }
681+
{ "revision": 1, "date": "2026-08-08", "change": "new item: personal API key mint (show-once, POST /api/v1/keys) → authenticate-as-owner via x-api-key → revoke 401 → restore 200, plus mine-view vs admin all-view scoping, grounded in AgentConnectSection + sys_api_key object (PENDING-GAPS §B)", "ref": "claude/platform-test-checklist-ocwugl" },
682+
{ "revision": 2, "date": "2026-10-16", "change": "stale oracle re-pointed (#23992): step 4 and acceptance[2] expected the post-revoke x-api-key call to answer 401, but since the API-key lifecycle (#23367 ruling A, executed by #23541) a recognised revoked key is refused 403 with error.code API_KEY_REVOKED (packages/core/src/security/api-key.ts#lifecycleRefusalOf) and only an unknown key gets the generic 401, so the item scored a correct runtime red. The step and the clause now capture the 403, the code and the absence of rows, and pin none of the refusal's wording (#23944 rewords it). Not loosened: a 200, a generic 401 and a 403 with any other code each still FAIL (the negative names both directions). Step 5 (restore → 200) is unchanged; source gains the refusal's decision and relay sites", "ref": "#23992" }
681683
]
682684
},
683685
{

0 commit comments

Comments
 (0)