Skip to content

Commit 4ffdebe

Browse files
fix(core): a revoked API key's refusal names the restore road instead of claiming permanence (#23991)
Fixes #23944 Clause-②: no ## What changed A call made with a revoked API key is refused `403 API_KEY_REVOKED`, and the message said "a revoked key never works again". `sys_api_key` declares a `restore_api_key` row action whose PATCH `{ revoked: false }` brings the same key back at once, so the product's error text contradicted its own action. The revoked branch of `lifecycleRefusalOf` now reads: > This API key (PREFIX...) was revoked. Its owner or a platform administrator can restore it (the Restore API Key action on the API Keys list); otherwise create a new key (POST /api/v1/keys, or the API Keys page) and replace this one wherever it is configured. - `packages/core/src/security/api-key.ts`: the revoked message only. Code, status, order and the carried `keyId`, `userId`, `at` are untouched; the expired and inactive messages (and the shared `API_KEY_REMEDY` they use) are unchanged. - `packages/core/src/security/api-key-lifecycle.test.ts`: one pin (see Evidence). - `content/docs/api/error-catalog.mdx`: the `API_KEY_REVOKED` **Fix** line only (declared to domain:devx in the claim). - `.changeset/23944-revoked-key-refusal-text.md`: `@objectstack/core` patch. ## Which edit, and why The issue offered two: reword the refusal, or retire `restore_api_key`. The lifecycle ruling on #23367 (letter A) binds expiry, inactivity and distinct located codes and is silent on restore; `restore_api_key` predates it and its execution kept it, so nothing asks for a removal. The product states the same intent twice more: `docs/NORTH-STAR.md` line 105 ("能撤也能恢复") and the platform checklist item `identity-auth.api-key-ui-lifecycle` ("the lifecycle is reversible"). The declared action stands and the text is made to agree with it. `sys-api-key.object.ts` is not touched. ## Who can restore (the wording is this set, measured) `restore_api_key` is `PATCH /api/v1/data/sys_api_key/{id}` with `{ revoked: false }`, the same door as revoke. Measured at base `2054a0888` with the real `PermissionEvaluator` over `defaultPermissionSets` after `applyManagedWriteDenies` (the `kernel:ready` state), plus the sets' own row policies: | principal | `update` on `sys_api_key` | row scope | may restore | |:---|:---|:---|:---| | member (`member_default`) | true | `sys_api_key_self`, operation all, `user_id == current_user.id` | own key only | | organization admin (`organization_admin` + `member_default`) | true as the union with `member_default` (the set's own entry for the table is the managed-table deny, with neither super-user bit) | same self policy | own key only; not another user's | | platform admin (`admin_full_access`) | true | wildcard with `viewAllRecords` and `modifyAllRecords`, no row policy; deliberately not a managed-deny target | any key | | read-only (`viewer_readonly`) | false | select-only self policy | none | The column is bounded by ADR-0092 D2's write whitelist for this table (`revoked` alone). Setup's API Keys page, where an administrator sees every key, is gated on `manage_platform_settings`, which `organization_admin` withholds. So the set is the key's owner and a platform administrator, and the text says exactly that: "an administrator" would have invited an organization admin to a PATCH their row policy refuses (a member gets 404 on another user's key in the dogfood pin). The Cause line of the same catalog entry still says "an administrator" (loose usage, outside the claimed one-line surface). Exercised in CI by the existing dogfood files: `api-key-owner-revoke.dogfood.test.ts` (owner restores own key; cross-owner PATCH is 404) and `api-key-revoke-lifecycle.dogfood.test.ts` (admin revoke and restore). ## Evidence - **Red then green.** The pin fails on `origin/main`'s text and passes on this one. - Red: `node scripts/ablation-replace.mjs` put `origin/main`'s message line back on disk (anchor hit 1 time, blob `bf7d10c8` to `fbb1c846`), ran `vitest run --project local src/security/api-key-lifecycle.test.ts`: `1 failed | 20 passed (21)`, `expected 'This API key (osk_lifecycl…) was revo…' to contain 'Restore API Key'`. Restore proven: blob after restore equals blob at HEAD (`bf7d10c8`), `git diff HEAD` empty. Direction observed: red, the normal one. The subject is imported by relative path inside the package, so no `dist/` leg applies. - Green: same file on this head, with `api-key.test.ts` and `api-key-account-posture.test.ts`: 3 files, 60 tests passed. The whole `@objectstack/core` local project: 102 files, 2504 tests passed. - **The pin** asserts, through `resolveAuthzContext`, `code: API_KEY_REVOKED` and `status: 403` (the envelope), then that the message names the key's prefix, `Restore API Key`, who can restore it and `POST /api/v1/keys`, and matches none of never, permanent, forever, for good. - **Typecheck:** `pnpm --filter @objectstack/core typecheck` exit 0 (`tsc`, the examples program, `check:test-typecheck`). - **Gates:** `dispatch-gates --commands --repo objectstack-ai/objectstack` derived 94 families on this head; each was run with its exit code captured before any pipe and reconciled with `--ran`: 94 derived, 94 run, 0 NOT-MEASURED, 0 UNRUN, all exit 0. Six first answered 3 (`PREREQUISITE NOT MET`, packages unbuilt) and two timed out under box contention; all eight were re-run after `turbo run build --filter='./packages/*' --filter='./packages/*/*'` and with a longer timeout, and exit 0. The four roster families keyed on this card's directories (`check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`) and `check:error-code-provenance` also exit 0. Families CI runs are cited from this PR's check-runs, not re-run. - **No other copy of the sentence.** Re-grep of tracked files outside `content/docs/releases/` for "never works again": before the edit, 2 hits (`api-key.ts` and the catalog Fix line), control "was revoked, and a revoked key" 1 hit in `api-key.ts`; a wrapped-line variant search found no third. After the edit the only hits quote it on purpose (the changeset's FROM text and a comment in the pin). Nothing in the repository parses the message: `API_KEY_REVOKED` consumers match on the code, and the dogfood probes assert the code. ## Acceptance notes Not filed; noted for the reader. - `content/docs/ai/connect-mcp.mdx` ("The remedy is the same for all three: create a new key") and `content/docs/api/index.mdx` ("the remedy is a new key") are consistent with rotation, which works for all three codes; they simply do not mention restore for the revoked one. Outside the claimed surface, left alone. - The checklist item `identity-auth.api-key-ui-lifecycle` still expects "401" after revoke, while the runtime answers `403 API_KEY_REVOKED` since the lifecycle ruling; its sibling item for the lifecycle policy has the 403. Test-spec drift outside the claimed surface. - A key revoked by an administrator's forced password reset (`revokeSessionsAndKeys`) is restorable by its owner through the same row action, because restore is not conditioned on why the flag was set. The message cannot know the reason and states only the capability. Whether a compromise-response revocation should be owner-restorable is a product question this card does not decide. - An owner whose only resolved set is `viewer_readonly` cannot restore (update is false for that set); the text states the general capability. - Live boot of an administrator restoring another user's key was not run: that row of the table is the evaluator's reading of the sets and is not exercised by an existing dogfood file. ## Deviations - Commit trailers are the model-free pair AGENTS.md requires; the harness attribution reminder asked for a model-named trailer, which the pre-push hook refuses, so it was not used. - Base is `2054a0888`. `origin/main` moved four commits during the run (cli/runtime mkdir, objectui pin, a dogfood pin, platform-objects menu); none touches this diff's four paths. `dispatch-gates` reports three derivation inputs stale against `origin/main` (`check-cli-test-child-env.mjs`, `spec-shape-retirements.json`, `sdui-manifest.record.json`), none of them in this card's derived families. --- _Generated by [Claude Code](https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 61a6809 commit 4ffdebe

4 files changed

Lines changed: 51 additions & 3 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@objectstack/core': patch
3+
---
4+
5+
Clause-②: no
6+
7+
A call with a revoked API key no longer hears "a revoked key never works again". The key row's own Restore API Key action clears the flag and the same key works again at once, so the `403 API_KEY_REVOKED` message now says the key was revoked, that its owner or a platform administrator can restore it (the Restore API Key action on the API Keys list), and otherwise to create a new key (`POST /api/v1/keys`, or the API Keys page) and replace this one wherever it is configured. The code, the status, the fields the refusal carries and the expired and inactive messages are unchanged. The error catalog's `API_KEY_REVOKED` Fix line says the same.

‎content/docs/api/error-catalog.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -446,7 +446,7 @@ result set — a response indistinguishable from a successful query.
446446

447447
### `API_KEY_REVOKED`
448448
**Cause:** The API key was revoked by its owner or an administrator.
449-
**Fix:** Create a new key and replace this one wherever it is configured. A revoked key never works again.
449+
**Fix:** Ask the key's owner or a platform administrator to restore it (the Restore API Key action on the API Keys list); the same key then works again at once. Otherwise create a new key and replace this one wherever it is configured.
450450
**Retry:** `no_retry`
451451

452452
These three are answered on every endpoint that accepts an API key: the endpoints the runtime serves (the MCP HTTP endpoint among them), the REST data API, the settings, file-download, datasource admin and share-link routes, the marketplace install routes and the MCP stdio transport. An unknown key is always the generic 401.

‎packages/core/src/security/api-key-lifecycle.test.ts‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
* stamped onto `expires_at` when admitted, and refused with that date after;
1818
* - an admitted (or held) call stamps `last_used_at`, at most once an hour,
1919
* and a stamp failure never fails the request and is said once;
20-
* - each number is the source's, so a changed setting changes the verdict.
20+
* - each number is the source's, so a changed setting changes the verdict;
21+
* - the revoked refusal names the declared restore action beside the new-key
22+
* road and never claims the key is gone for good.
2123
*/
2224

2325
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -298,3 +300,33 @@ describe('[#23367] resolveAuthzContext raises the refusal, branded, and never fa
298300
expect({ userId: ctx.userId, credential: ctx.credential }).toEqual({ userId: OWNER, credential: 'api_key' });
299301
});
300302
});
303+
304+
describe('[#23944] the revoked refusal agrees with the declared restore action', () => {
305+
let warn: ReturnType<typeof vi.spyOn>;
306+
beforeEach(() => { warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); });
307+
afterEach(() => { warn.mockRestore(); });
308+
309+
it('names the restore road and the new-key road, and never says the key is gone for good', async () => {
310+
const err = await resolveAuthzContext({
311+
ql: makeQl({ revoked: true }),
312+
headers: headers(),
313+
nowMs: NOW,
314+
userAuthGates: undefined,
315+
apiKeyLifecycle: policy(),
316+
}).catch((e) => e);
317+
318+
// The envelope is untouched; only the prose moved.
319+
expect(isApiKeyLifecycleRefusalError(err)).toBe(true);
320+
expect({ code: err.code, status: err.status }).toEqual({ code: 'API_KEY_REVOKED', status: 403 });
321+
322+
// `sys_api_key` declares `restore_api_key`, whose PATCH `{ revoked: false }`
323+
// brings the same key back: a refusal that says "never works again" is
324+
// contradicted by the product's own action. Name the action by its label,
325+
// name who may run it, and keep rotation as the other road.
326+
expect(err.message).toContain('osk_lifecycl');
327+
expect(err.message).toContain('Restore API Key');
328+
expect(err.message).toMatch(/owner or a platform administrator can restore it/);
329+
expect(err.message).toContain('POST /api/v1/keys');
330+
expect(err.message).not.toMatch(/never|permanent|forever|for good/i);
331+
});
332+
});

‎packages/core/src/security/api-key.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,16 @@ function lifecycleRefusalOf(
306306
if (row.revoked === true || row.revoked === 1) {
307307
return {
308308
code: API_KEY_REVOKED_REFUSAL.code,
309-
message: `${label} was revoked, and a revoked key never works again. ${API_KEY_REMEDY}`,
309+
// Revocation is the `revoked` flag, and `restore_api_key` clears it again
310+
// (`sys_api_key`'s own row action): the key works at once, no re-issue.
311+
// Who may clear it is who may set it — the key's owner (own rows, `revoked`
312+
// alone) and a platform administrator (any row) — so the text names those
313+
// two and never claims the key is gone for good. Rotation stays the other
314+
// road, for a key that should not come back.
315+
message:
316+
`${label} was revoked. Its owner or a platform administrator can restore it (the Restore API Key action `
317+
+ 'on the API Keys list); otherwise create a new key (POST /api/v1/keys, or the API Keys page) and replace '
318+
+ 'this one wherever it is configured.',
310319
...ids,
311320
};
312321
}

0 commit comments

Comments
 (0)