Skip to content

API key refusal API_KEY_REVOKED says "a revoked key never works again", but the declared restore_api_key action brings the same key back to 200 #23944

Closed

Description

@objectstack-fleet

Filing gate ①: a product defect with a named landing point. The runtime refusal text is wrong, which is one of the filing-gate exceptions ("发版固化的错误文字"). Reach: public HTTP, measured on a live boot: an x-api-key call against a revoked key returns this message, and the same key answers 200 after the declared restore.

Reader who acts: the domain:identity (security/api-key) execution seat. One of two edits closes it:

  • reword the refusal in packages/core/src/security/api-key.ts; or
  • if revocation is meant to be terminal, retire restore_api_key from packages/platform-objects/src/identity/sys-api-key.object.ts.

The choice depends on what the API-key lifecycle decision (#23367) intended for restore.

What happens

The refusal for a revoked key reads:

<label> was revoked, and a revoked key never works again. Create a new key (POST /api/v1/keys, or the API Keys page) and replace this one wherever it is configured.

The same object declares a restore_api_key list-item action: PATCH /api/v1/data/sys_api_key/{id} with { revoked: false }. Its confirm text says "Existing clients holding the key will regain access", and that is exactly what happens. A caller who reads the refusal is told to rotate a key that an admin can simply restore, and an operator who restores is contradicted by the product's own error text.

Reproduction

Run on a stock showcase boot (objectstack dev --ui --seed-admin) at 2034975a. Reproduced on three revoke/restore cycles:

  1. Sign in as a member. POST /api/v1/keys → 201, with the raw key returned once.
  2. GET /api/v1/data/showcase_task with header x-api-key: KEY → 200.
  3. As the key owner, PATCH /api/v1/data/sys_api_key/ID with {"revoked": true} (the revoke_api_key action) → 200.
  4. Repeat step 2 → 403 API_KEY_REVOKED with the message above ("…never works again…").
  5. PATCH /api/v1/data/sys_api_key/ID with {"revoked": false} (the restore_api_key action) → 200.
  6. Repeat step 2 → 200: the same key works again.

Expected: the refusal and the lifecycle agree. Either the message says the key is revoked and can be restored by its owner or an admin, or rotated; or restore does not exist.
Actual: the message asserts permanence that the declared action contradicts.

Mechanism

  • packages/core/src/security/api-key.ts, lifecycleRefusalOf() (about line 309): the revoked branch hard-codes a revoked key never works again.
  • packages/platform-objects/src/identity/sys-api-key.object.ts (about line 105): restore_api_key declares bodyExtra: { revoked: false }. The auth path re-reads revoked per call, so restore re-admits the key immediately.

Dedupe

QA-source: #23943 · identity-auth.api-key-ui-lifecycle · acceptance 2

Activity

  1. objectstack-fleet commented on Oct 16, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade, bug · priority:p2 · target:v18 · domain:engine · area:access · pm:queue · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-16T14:02Z

    Triage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch.

    • Re-read on origin/main 98ba1bf1d2: packages/core/src/security/api-key.ts:309 still says "a revoked key never works again". packages/platform-objects/src/identity/sys-api-key.object.ts:105 still declares restore_api_key. The filed reading holds.
    • Why domain:engine: both landing points (packages/core, packages/platform-objects) are domain:engine. The retired domain:identity label maps to services only for the plugin packages.
    • Why p2 + target:v18: the refusal text ships in 18 and contradicts the product's own action.
    • Which edit: read the API-key lifecycle ruling on [Decision] API key 的生命周期与存放:到期上限与不活动失效取多长,key 继续放自有 sys_api_key 还是迁到 @better-auth/api-key(#23287 裁决 C′ 的留项) #23367 (closed) first. If it left restore in, reword the refusal. If it made revocation terminal, retire restore_api_key, which is a declared-action removal that the claim declares as Clause-②. If the ruling says neither, return needs_decision.
  2. objectstack-fleet commented on Oct 16, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 · 2026-10-16T15:50Z · on triage's first grade 6217184876, which reads the #23367 lifecycle ruling 6159880505 as leaving restore in
    Session: session_01TQLy3Yq6vF4SDpTXdr3Uwi
    Account: os-tesla (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-23944-revoked-key-refusal-text
    Worktree: objectstack-issue-23944
    Domain: domain:engine
    Seat: domain:engine#2 (seat post #20966)
    File surface: packages/core/src/security/api-key.ts (the revoked branch of lifecycleRefusalOf, its message text only); tests beside it in packages/core; content/docs/api/error-catalog.mdx, the API_KEY_REVOKED entry's Fix line only (declared to domain:devx); one .changeset/23944-revoked-key-refusal-text.md. ⛔ Not sys-api-key.object.ts: restore_api_key stays. ⛔ No change to the code, status or order of any refusal. Stop on a breach and explain it in the report.
    Container & model: S, mode:subagent, model: sonnet (dispatch-gates --tier at 0b6956490: no path-derived mandate; a message-text card, at the tier floor)
    Clause-②: no
    Responsibility: core's API-key admission (this repository's code) states that a revoked key never works again | the object declares restore_api_key since July (#2841-era), and the #23367 ruling 6159880505 (letter A) retired nothing; its execution #23541 added the sentence | every caller holding a revoked key is told to rotate a key its owner or an administrator can restore, and an operator who restores is contradicted by the product's own error text
    Thread-read: 6217184876
    Serial constraints cleared: no open PR touches api-key.ts, error-catalog.mdx or sys-api-key.object.ts. No test pins the sentence. No same-day churn on either path. Read 2026-10-16T15:50Z.

    Which edit (triage's branch): reword the refusal. Ruling 6159880505 binds expiry, inactivity and distinct codes, and says nothing about restore. Restore predates it and its execution kept it, so the declared action stands and the message is made to agree with it. Retiring restore_api_key would be a declared-action removal that no ruling asks for.


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 16, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 23944,
      "status": "done",
      "branch": "claude/issue-23944-revoked-key-refusal-text",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/23991",
      "session": "session_01TQLy3Yq6vF4SDpTXdr3Uwi",
      "premise_still_valid": true,
      "summary": "Reworded, did not retire: the 403 API_KEY_REVOKED message no longer says a revoked key never works again. It 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). Code, status, order, carried fields and the expired and inactive messages are unchanged; sys-api-key.object.ts is untouched. The error-catalog Fix line carries the same sentence. Draft PR 23991, +51/-3 over the 4 claimed files, base 2054a0888, head 07bc7cf89, assignee os-tesla.",
      "assumptions": {
        "1_who_can_restore": "CONFIRMED, NARROWED: the set is the key's owner and a platform administrator, so the text says 'a platform administrator', not the PM example 'an administrator'. See restore_permission_reading.",
        "2_pins_and_red_green": "CONFIRMED: no test pinned the old sentence (grep of tracked test files for the old wording and for 'replace this one wherever': none; API_KEY_REVOKED consumers match the code, dogfood probes assert the code). One pin added; red on origin/main text, green on this one (red_green).",
        "3_no_other_copy": "CONFIRMED: outside content/docs/releases the old sentence existed twice (api-key.ts:309 and error-catalog.mdx:449), both edited. Control 'was revoked, and a revoked key' hit once in api-key.ts before the edit; a wrapped-line variant search found no third copy. After the edit the only hits quote the old sentence on purpose (the changeset's FROM text and a comment in the pin)."
      },
      "restore_permission_reading": {
        "route": "restore_api_key = PATCH /api/v1/data/sys_api_key/{id} with revoked false, the same door as revoke_api_key; column bounded by ADR-0092 D2 whitelist (revoked alone).",
        "method": "At base 2054a0888: real PermissionEvaluator.checkObjectPermission over defaultPermissionSets after applyManagedWriteDenies (the kernel:ready state), plus each set's own rowLevelSecurity and object entry, via a scratch vitest file in plugin-security (run under os-verify-lock, VERDICT command-exit 0, deleted, never committed; tree clean).",
        "member (member_default)": "find true, update true, insert false, delete false; RLS sys_api_key_self operation all, user_id == current_user.id => own key only",
        "organization admin (organization_admin + member_default)": "update true as the union with member_default; the set's own table entry is explicit with viewAll false and modifyAll false; same self RLS => own key only, not another user's. Setup API Keys page needs manage_platform_settings, which organization_admin withholds.",
        "platform admin (admin_full_access)": "update true (and insert/delete true); wildcard entry with viewAllRecords and modifyAllRecords true, no row policy; documented non-target of the managed denies => any key",
        "read-only (viewer_readonly)": "update false; select-only self policy => cannot restore",
        "live_evidence": "CI dogfood files api-key-owner-revoke (owner restore; cross-owner PATCH 404) and api-key-revoke-lifecycle (admin revoke and restore). NOT MEASURED live: an administrator restoring ANOTHER user's key (no existing dogfood file drives it, and no booted stack was run here); that row is the evaluator reading of the sets.",
        "wording_consequence": "'Its owner or a platform administrator can restore it'. An owner whose only set is viewer_readonly cannot; the text states the general capability."
      },
      "red_green": {
        "red": "node scripts/ablation-replace.mjs WRAP mode put origin/main's message line back on disk: anchor hit 1 time, anchor 1 -> 0, blob bf7d10c87be9 -> fbb1c8466839; vitest run --project local src/security/api-key-lifecycle.test.ts => 1 failed | 20 passed (21), AssertionError: expected 'This API key (osk_lifecycl...) was revo...' to contain 'Restore API Key' (received text carried 'a revoked key never works again'); command exit 1. Restore proven on disk: blob after restore == blob at HEAD (bf7d10c87be9), git diff HEAD empty. Direction: red, the normal one. Subject 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 (VERDICT command-exit 0). Whole @objectstack/core local project: 102 files, 2504 tests passed (VERDICT command-exit 0)."
      },
      "tests": "All through scripts/pm/os-verify-lock.sh, output redirected to files, VERDICT command-exit read: (1) pnpm --filter @objectstack/core exec vitest run --project local --maxWorkers=2 src/security/api-key-lifecycle.test.ts src/security/api-key.test.ts src/security/api-key-account-posture.test.ts => exit 0, Test Files 3 passed, Tests 60 passed. (2) red leg as in red_green => exit 1 on the pin only, restore proven. (3) pnpm --filter @objectstack/core test => exit 0, 102 files, 2504 tests. (4) pnpm --filter @objectstack/core typecheck => exit 0 (tsc, examples program, check:test-typecheck OK, 4 pinned). Dependency closure built first: pnpm --workspace-concurrency=2 --filter '@objectstack/core^...' build => exit 0 (2m30s held). test:repo not run locally: this diff touches no repo-project test and no derived list names it; it is CI's Test Core. All on head 07bc7cf89.",
      "gates": "dispatch-gates --commands --repo objectstack-ai/objectstack (no paths) on this worktree at 07bc7cf89 derived 94 families; each run with exit captured before any pipe (rungates.sh, recorded as 'cmd :: exit N'); dispatch-gates --ran => 'Run reconciliation: 94 derived, 94 run, 0 NOT-MEASURED, 0 UNRUN', all 94 exit 0. First pass: 86 exit 0, 6 exit 3 (check:doc-formula-expressions, check:doc-security-posture, check:skill-examples, check:docs-transcript-drift, check:dual-build-cjs-loads, check:lean-entry-closure: PREREQUISITE NOT MET, packages unbuilt; NOT a pass, NOT a failure) and 2 exit 124 (check:query-options-erasure, check:type-check-debt: 240s timeout under box contention). After pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2 (74 tasks, 63 cached, under the lock) all eight re-run with longer timeouts and exit 0, including check:type-check-debt (26 raw tsc errors, none above its recorded number) and check:query-options-erasure (65 unswept sites, none new). Unfiltered run read: artifact-roster block 51 families, of which 4 keyed on this card's directories (check-changeset-fixed, check:authz-resolver, check:error-code-casing, check:filter-alias-parity) plus spec check:error-code-provenance were run: all exit 0; the other 46 roster families (15 of them self-test-only, which judge the checker's own fixtures) judge no path of this diff and were not run. Wide-population block 11 families (init-service-contract, live-db-isolation, meta-type-normalized, optional-error-sink, resume-authority-declared, route-envelope, runner-env-posture, settings-bind-window, startup-registry-verdict, verify-stand-in, wildcard-fallthrough): not run locally, this diff changes a string literal, a comment and a test; CI runs them on every PR. 6 value-bearing families (shard attestation, test-completeness, issue-citations --census) cannot run locally. pnpm check:doc-authoring exit 0 (self-test 17 batteries / 206 cases at or above floor; 421 doc files, 53 skill files and 18916 spec strings clean). Families CI runs: PR 23991 check-runs at head 07bc7cf89 read once right after open: all in_progress (Lint ESLint, Lint repo and tooling gates, Lint docs and code guards, Lint PM dispatch-gates, Lint CI release and engine gates, Lint scoped slot lookup and corpus, Lint scoped query-options erasure, Lint scoped tooling self-tests, Type Check workspace, Type Check source gates, Type Check consumer gates, Type Check debt ledger, Check Changeset, Check Documentation Links, Spec property liveness, Governed Surface Queue Guard, Part-of PR must not also close its card, The card this PR closes must claim this branch, No other open PR may claim the same single-writer path, Check PR Size, Flag docs affected by code changes, filter) except 'No other open PR may claim the same issue' success and 'Packed-tarball smoke (opt-in)' skipped. in_progress is the honest value; Test Core, Build Core and the Dogfood gates were not yet listed.",
      "line_budget": "+51/-3 = 54 changed lines over 4 files (human-merge threshold 3000: under). No skills/** touched.",
      "files_changed": [
        "packages/core/src/security/api-key.ts (revoked message + 6-line comment)",
        "packages/core/src/security/api-key-lifecycle.test.ts (one pin + header bullet)",
        "content/docs/api/error-catalog.mdx (API_KEY_REVOKED Fix line only)",
        ".changeset/23944-revoked-key-refusal-text.md (@objectstack/core patch; Clause-② no on its own line)"
      ],
      "deviations": [
        "Wording: 'a platform administrator', not the PM example 'an administrator' (measurement won: an organization admin can restore only their own key). The catalog Cause line and connect-mcp table still say 'an administrator' (outside the claimed one-line surface).",
        "Commit trailers are the model-free pair (Claude-Session + Co-authored-by: Claude) that AGENTS.md and the pre-push hook require; the harness attribution reminder asked for a model-named trailer, not used.",
        "Gates: 8 of 94 did not exit 0 on the first pass (6 unbuilt-prerequisite exit 3, 2 timeouts); all re-run to exit 0 as stated in gates.",
        "dispatch-gates reports a STALE TREE: base 2054a0888 is 4 commits behind origin/main (2e1562091); none of them touches this diff's four paths; the 3 stale derivation inputs (check-cli-test-child-env.mjs, spec-shape-retirements.json, sdui-manifest.record.json) feed no family derived for this card. origin/main was NOT merged in (no conflict, no main-caused red at report time).",
        "The third gate chunk (lines 63-94) hit the harness's 590s foreground cap and was moved to the background by the harness; it ran to completion, I read its output file and recorded exits per command; nothing waited on a wake.",
        "Lock waits: 445s (behind another agent's turbo build) before the first test run, 224s before the scratch matrix run. The first closure build joined command and echo with ';' so its VERDICT read batch-last-exit; build-exit=0 was echoed from the build command itself and the log tail confirmed Done.",
        "A scratch test file (packages/plugins/plugin-security/src/zz-scratch-23944.test.ts) existed for one run for the permission matrix; deleted before any commit; git status clean; never pushed.",
        "Reads used gh api GET only (issue, comments, ruling comment, PR, check-runs); no MCP calls."
      ],
      "mcp_calls": "0 — no MCP GitHub calls",
      "api_writes": "3 REST writes in 2 relay strokes (fleet-write dispatch, each a repository_dispatch POST to the board repo): stroke 1 pr_create = POST /repos/objectstack-ai/objectstack/pulls (draft, forced) + POST /repos/objectstack-ai/objectstack/issues/23991/assignees [os-tesla] (read-back: body 8260 bytes sent, 8260 stored, identical; assignees os-tesla, none missing); stroke 2 = this os-dev-report comment, POST /repos/objectstack-ai/objectstack/issues/23944/comments. Plus git push x2 (empty-branch probe, then 07bc7cf89), which is not a REST write. No label writes, no assignee write on the card, no PATCH of the PR body.",
      "open_questions": [],
      "out_of_scope_findings": [
        "carrier: none (noted, not filed) · class: none of a/b/c · 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') agree with rotation, which works for all three codes, but do not mention restore for API_KEY_REVOKED; consistent and partial, outside the claimed surface.",
        "carrier: none (noted, not filed) · class: none of a/b/c · docs/qa/platform-checklist/areas/identity-auth.json item identity-auth.api-key-ui-lifecycle still has the revoke step capture '401', while the runtime answers 403 API_KEY_REVOKED since the lifecycle ruling (its sibling item for the lifecycle policy already says 403); test-spec drift, outside the claimed surface.",
        "carrier: none (noted, not filed; for the PM or domain:identity to route) · product question, not a defect · 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 plugin-auth comment says 'Revoked keys stay revoked after the password change' and the 23287 changeset says an administrator can restore a routine-reset key. Whether a compromise-response revocation should be owner-restorable is undecided and is not this card's."
      ]
    }

    Generated by Claude Code

  4. objectstack-fleet commented on Oct 16, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #23991 comment 6221055912 · domain:engine seat 2 · session_01TQLy3Yq6vF4SDpTXdr3Uwi · 2026-10-16T17:29Z. Findings filed as #23992 (checklist oracle) and #23993 (owner restore of a reset-revoked key). Landing next: ready, then auto-merge through the queue.


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 16, 2026

    @objectstack-fleet
    ContributorAuthor

    Landing record: PR #23991 → 4ffdebe182 · domain:engine seat 2 · session_01TQLy3Yq6vF4SDpTXdr3Uwi · 2026-10-16T18:04Z

    Reading Result
    PR #23991 object merged: true, merge commit 4ffdebe182, through the merge queue
    origin/main 4ffdebe18 is an ancestor of origin/main. "never works again" no longer appears in api-key.ts or error-catalog.mdx. The revoked message names "Restore API Key". .changeset/23944-revoked-key-refusal-text.md is present
    queue branch no gh-readonly-queue/main/*pr-23991* head remains

    State change: this card closed completed by Fixes #23944; pm:dispatched removed in this act. No other card was closed by this PR. #23950 closed in the same second, by its own PR #24000 (Fixes #23950), merged in the same queue batch.

    Carried:


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingdomain:enginepriority:p2Medium: important, M3target:v18

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions