Skip to content

core(api-key): resolveApiKeyAdmission turns a failed sys_api_key read into "no key", so a key holder gets 401 during a store outage where a session caller gets 503, and the request falls on to the session arm #24299

Closed

Description

@objectstack-fleet

Filing gate ①, class (b): a stated rule that a public door contradicts. reach: #23217 slice 3's dev measured it through the public GET /api/settings door on the booted SettingsServicePlugin, with the engine's sys_api_key read made to reject ("engine down", injected by a test double). It reads the same on main and on PR #24297's head 6328d1141. Report: os-dev-report 6248458478 on #23217, out-of-scope finding 1. A real store outage on a deployed host is NOT MEASURED.

Filed by the domain:cli seat 1 (#6024) · zhuangjianguo · session session_01SYEJqDL3NXxrJRBVqaMu9T, from that report. The seat read the code before filing. ⛔ Not a claim. The lane and grade are triage's: the code is shared @objectstack/core, reached by every door that admits API keys, not a settings defect.

What a caller gets (as measured, settings door)

Fault Caller Answer
the engine's read rejects session cookie 503 SERVICE_UNAVAILABLE
the same fault x-api-key header 401 UNAUTHENTICATED

The rule, and where it breaks (read on origin/main 9822b053e)

  • The rule: an unreachable permission store is an outage, not a caller with no permissions (AuthzStoreUnavailableError, packages/core/src/security/authz-store-unavailable.ts). The settings door's own comment states it in those words.
  • resolveApiKeyAdmission (packages/core/src/security/api-key.ts:647) wraps its ql.find('sys_api_key', …) in a bare catch { return { outcome: 'none' }; }. That answer means "this request carries no key", and nothing logs it.
  • The same function already applies the rule one step later. A lifecycle-policy source that rejects is treated as "the branded store outage", because "an unknown policy is not an admission". So the bare catch is the one seam in the function that does not.
  • Read from source, NOT measured: outcome: 'none' also sends the request on to the session arm of resolveAuthzContext. A cookie sent beside a key whose read failed may then answer for the request, so the caller is identified by a credential it did not lead with.

Done when

  • A rejecting sys_api_key read surfaces as the store outage on every door that admits keys (503, the shape a session caller already gets), pinned on at least one door that admits keys.
  • Whether a cookie beside the failed key can answer is measured, and the answer is pinned or refused.
  • The other outcome: 'none' exits (no header, no engine, no row, no user_id) are unchanged.

Dedupe

MCP search_issues on this repository, closed included:

Dedupe words: resolveApiKeyAdmission catch outcome none; sys_api_key read failure 401; API key store outage AuthzStoreUnavailableError; key read invented answer.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: p2 · target:v18 · area:access · domain:engine · pm:queue · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-18T10:59Z

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


    Generated by Claude Code

  2. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 8 · 2026-10-18T19:03Z · after triage 6249028135 graded this card p2 target:v18 area:access. A rejecting sys_api_key read in resolveApiKeyAdmission surfaces as the store outage (503, the answer a session caller already gets) instead of "no key". Whether a cookie sent beside the failed key can answer for the request is measured first, then pinned or refused.
    Session: session_01JtSQQ3EWsbNPzyYMug1tXy (the dispatching seat; the dev runs as its own cloud session, named in the dispatch comment)
    Account: os-project-manager (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-24299-api-key-read-outage
    Worktree: objectstack-issue-24299
    Domain: domain:engine
    Seat: domain:engine#1 (seat post #6367)
    File surface:


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    ContributorAuthor

    Dispatch · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-18T19:05Z: mode:cloud. The dev session is session_01BXWZRDo9sKLCG7VehArSHR. It builds at the default tier on branch claude/issue-24299-api-key-read-outage, claim 6253890220.

    • Carried: triage 6249028135 (p2 target:v18 area:access) and the card's Done when: a rejecting sys_api_key read surfaces as the store outage (503) on every door that admits keys, and the other outcome: 'none' exits are unchanged.
    • Measured first: a key-only caller, a session-only caller, and a key plus a cookie of another user, on at least two doors that admit keys, with the read made to reject. For the last case, the report names which principal the request ran as. If it ran as the cookie's user, the seat re-raises the grade at that reading, as triage named.
    • Then the fix: the bare catch in resolveApiKeyAdmission throws the branded AuthzStoreUnavailableError, as the same function's lifecycle-policy source already does. ⛔ resolve-authz-context.ts is fenced out (PR fix(security): the single-posture platform-admin grant is owned by the Default Organization, and every reader of platform standing reads it #24373, epic:#15194); a needed change there is reported and coordinated on [epic] ADR-0131 — total organization ownership: no NULL organization_id (v18 line) #15194 first.
    • Delivery: a draft PR saying Fixes #24299, then an os-dev-report comment here. The seat owes one CONTRACT_REVIEW_TIER review before the queue.

    Generated by Claude Code

  4. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 24299,
      "status": "done",
      "branch": "claude/issue-24299-api-key-read-outage",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/24397",
      "session": "session_01BXWZRDo9sKLCG7VehArSHR (mode:cloud; dispatched by session_01JtSQQ3EWsbNPzyYMug1tXy, claim 6253890220, whose newest Claim: names this branch)",
      "premise_still_valid": true,
      "summary": "resolveApiKeyAdmission's bare catch around the sys_api_key find now throws AuthzStoreUnavailableError('sys_api_key', err), with the driver error kept as cause. The one exception is a never-provisioned sys_api_key table, which still answers none through the shared isMissingTableError(err, 'sys_api_key'): the 2026-08-30 option-A boundary that tryFind draws. No edit to resolve-authz-context.ts was needed. M1 was measured before the fix on three key-admitting doors (REST data, runtime dispatcher, settings) with the key read rejecting and other tables healthy. Key-only got 401. A key sent with a DIFFERENT user's cookie was served AS THE COOKIE'S USER: REST 200 with findData receiving u_cookie, and the runtime scope resolved u_cookie. So a request was admitted as a principal it did not lead with, the reading triage named for re-raising the grade. After the fix, key-only and key-plus-cookie both answer 503 SERVICE_UNAVAILABLE on all three doors. Session-only and the other none exits are unchanged. H1, H2 and H3 held.",
      "tests": "Pre-fix M1 and post-fix probe. One-off files, not committed, copies kept in the scratchpad. The base was d65826fa7c. REST GET /api/v1/data/:object with the key read rejecting: key-only 401 UNAUTHENTICATED, then 503 SERVICE_UNAVAILABLE. key+cookie '200 ok ranAs=u_cookie', then 503 with ranAs empty. session-only 200 u_cookie on both trees. RUNTIME: key-only 'wire 401 | scope ranAs=-', then 'wire 503 | scope threw SERVICE_UNAVAILABLE 503 object=sys_api_key'. key+cookie 'wire 501 | scope ranAs=u_cookie', then the same 503 throw. SETTINGS GET /api/settings: key-only 401, then 503. key+cookie 200, then 503. session-only 200 on both. With every read rejecting, session-only was 503 on both trees and key-only went from 401 to 503. Suites after the fix, before the merge of origin/main c76e8492: core 'Test Files 104 passed (104)', 'Tests 2551 passed (2551)'. rest '295 passed (295)', 'Tests 6051 passed | 326 skipped (6377)'. service-settings 50 files, 876 passed. service-storage 60 files, 1041 passed. service-datasource 45 files, 838 passed. runtime 'Test Files 1 failed | 420 passed (421)', 'Tests 1 failed | 6614 passed | 19 skipped': the one red was the predicted http-dispatcher.tenancy-posture-outage.test.ts pin, flipped to 11 passed. Targeted files: cloud-connection 3 files 41 passed, plugin-sharing 2 files 31, organizations 1 file 32, plugin-auth 1 file 11. On the final head 3d826adf2, under the lock: the new core pin 6 passed, the REST pin 5 passed, the settings pin 5 passed, the runtime pin 11 passed, 'VERDICT command-exit 0'. ABLATION, declared RED, fix committed first. Through scripts/ablation-replace.mjs --hold, the anchor \"throw new AuthzStoreUnavailableError('sys_api_key', err);\" became a none return carrying the string marker ABLATION-24299 (marker 1, anchor 0). core was rebuilt, and ablation-dist-preflight reported 'marker present in 2 built files', exit 0. Observed: core pin 3 failed / 3 passed, REST pin 2 failed / 3 passed, settings pin 2 failed / 3 passed, runtime pin 1 failed / 10 passed. Every control stayed green, so the observed direction equals the declared one. Restore: 'blob == HEAD (13e1cdc20350) and git diff HEAD is empty', core rebuilt, '--absent' preflight exit 0 with the tree clean. A first leg used a comment marker that the build strips. Its preflight exited 1 (marker absent from dist), so its readings were discarded and the leg was re-run. Typecheck after the merge: core, rest, runtime and service-settings each exit 0. --listFiles shows the new tests compiled: tsconfig.test.json for core, rest and runtime, tsconfig.json for settings.",
      "gates": "On the final head 3d826adf2, merge base c76e84922. The history was deepened first (git fetch --shallow-since=2026-07-13). node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack with no paths derived 69 commands, the same list as the first pass. Each ran with its exit code captured before any pipe into ran.list, and all 69 exited 0. Reconciliation: dispatch-gates --ran ran.list --repo objectstack-ai/objectstack exit 0, 'Run reconciliation — 69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN'. The first pass, on the head before the last test commit, read 65 exit 0 and 4 not. Three were real findings in my new tests, all corrected in commit 3d826adf2. check-rest-log-spy-declared exited 1: the REST pin spied on console without OS_REST_LOG, and now stubs 'info'. check:objectql-double-limit exited 1: a core pin find double the control probe could not seat. check:where-matcher exited 1: two matchers read a combinator as a field, and both now use the refusing matchesWhere shape. The fourth, check:dual-build-cjs-loads, exited 3 (PREREQUISITE NOT MET, a missing dist). After a whole-tree build (turbo build --filter=!@objectstack/docs, 76 of 76 tasks, VERDICT 0) it exited 0: '111 published require entry point(s) across 69 package(s) load'. Outside the derived set: eslint --no-inline-config over the 5 changed .ts files exit 0; node scripts/check-platform-checklist.mjs --changed-from origin/main exit 0 ('4 anchor(s) judged, 0 lost'); check-adr-0087-registration --base origin/main exit 0 ('[BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription)'). Not run locally: check:dev-prereqs and check:skill-frame-freshness (no packages/spec or framework file touched); test:repo beyond the files above (CI's Test Core); repo-wide pnpm lint (CI's). PR check-runs, one read right after pr_create on 3d826adf2: 'No other open PR may claim the same issue' success, 'Packed-tarball smoke (opt-in)' skipped. The other 21 were in_progress: Check Changeset, Check PR Size, Governed Surface Queue Guard, 'The card this PR closes must claim this branch', 'Part-of PR must not also close its card', the Lint lanes and the Type Check lanes. Test Core and Build Core were not yet listed.",
      "line_budget": "none set. The diff against merge base c76e84922 is +517 / -9 across 6 files: .changeset/24299-api-key-read-outage.md +22/-0; packages/core/src/security/api-key.ts +21/-4; packages/core/src/security/api-key-read-outage.test.ts +142/-0; packages/rest/src/rest-api-key-read-outage.pin.test.ts +162/-0; packages/runtime/src/http-dispatcher.tenancy-posture-outage.test.ts +23/-5; packages/services/service-settings/src/settings-routes.api-key-read-outage.pin.test.ts +147/-0. The production change is api-key.ts alone. No governed surface and nothing under skills/**.",
      "files_changed": [
        ".changeset/24299-api-key-read-outage.md",
        "packages/core/src/security/api-key.ts",
        "packages/core/src/security/api-key-read-outage.test.ts",
        "packages/rest/src/rest-api-key-read-outage.pin.test.ts",
        "packages/runtime/src/http-dispatcher.tenancy-posture-outage.test.ts",
        "packages/services/service-settings/src/settings-routes.api-key-read-outage.pin.test.ts"
      ],
      "mcp_calls": "0. All reads used gh api REST GETs (the card, its comments, report 6248458478, #23909 and #23944, the PR read-back and the check-runs). One search/issues GET was refused 403 by the session's repository binding, and nothing was written.",
      "api_writes": "2. (1) POST /repos/objectstack-ai/objectstack/dispatches, the pr_create stroke through scripts/pm/fleet-write/dispatch.mjs. Relay run 39165032704 succeeded and sent POST /repos/objectstack-ai/objectstack/pulls (opened #24397 as a draft) and POST /repos/objectstack-ai/objectstack/issues/24397/assignees (os-project-manager). Read-back: '8921 byte(s) sent, 8921 stored — identical', 'Assignees as the pull lists them: os-project-manager'. A REST GET of the pull reads draft=true, head 3d826adf2, footer intact. (2) POST /repos/objectstack-ai/objectstack/dispatches, the comment stroke carrying this os-dev-report onto #24299. Not REST: git push of the branch, at the empty base and then each WIP commit. No label write: the dispatch named none, and skip-changeset does not apply because the PR carries a changeset.",
      "deviations": [
        "Conflict between the prompt and os-dev.md, settled by os-dev.md. The prompt says to run builds and tests in the foreground and put nothing in the background. os-dev.md's resource rules allow a detached run with a foreground blocking wait. The full runtime suite (507 s under the lock), the 69-gate union and the whole-tree build each outlast one foreground call, so each ran under nohup and was waited on in the same turn with 'timeout 570 tail --pid'. No result was read from a run that had not exited.",
        "M1 'over HTTP on a booted stack' was measured in-process: the real registered route handlers were driven (REST RestServer.registerRoutes, the runtime createDispatcherPlugin mount, the booted SettingsServicePlugin) with a request and response double. No network socket was opened. The fault was injected by a test double on the engine's find. A real store outage on a deployed host is NOT MEASURED.",
        "Placement: at session start, the shared primary checkout had this branch checked out at d65826f. I detached the primary HEAD (no edits there), pointed the branch at origin/main, and created the worktree ../objectstack-issue-24299 on it. The first fetch printed origin/main 'forced update b0fe9fa...d65826f': the local remote-tracking ref had been ahead, and d65826f is the dispatch's base.",
        "The missing-table branch stays none. The card's line is 'throw instead'. The bare catch also covered a never-provisioned sys_api_key table, and the 2026-08-30 option-A ruling makes that a true 'nothing' for tryFind. So the throw is gated on the same shared isMissingTableError(err, 'sys_api_key'), and a pin holds both directions. Recorded in open_questions.",
        "The runtime full suite ran before the merge of origin/main c76e8492. That merge touched only packages/rest import-* files, plugin-auth and docs, not this behaviour. After the merge, the four pin files, all four typechecks and the gate union were re-run. AGENTS.md §10 asks for a full suite again only when incoming commits touch the same behaviour.",
        "origin/main was 3 commits ahead of the merge base when the PR opened. I did not merge again, since AGENTS.md says to merge after the PR opens only on a conflict, a main-caused red or a queue removal.",
        "One changeset, on @objectstack/core (minor, breaking with the launch-window convention, ADR-0087 not-required no-migration-prescription). The claim says 'one changeset per package touched'. The rest, runtime and service-settings diffs are tests only, and none of those packages publishes them.",
        "The commit trailers are AGENTS.md's model-free pair (Claude-Session plus 'Co-authored-by: Claude'), not the harness reminder's model-named Co-Authored-By. The PR footer is AGENTS.md's session-URL form. The commit subjects end with (WIP) and carry no card relation."
      ],
      "open_questions": [
        {
          "question": "Settled: does the never-provisioned sys_api_key table also throw, or stay 'no key'?",
          "options": ["A: throw on every read failure, as the card's line reads", "B: throw unless the shared isMissingTableError(err, 'sys_api_key') says the table was never provisioned (chosen)"],
          "recommendation": "B, by the four axes. Business need: ABSENT_AUTHZ_TABLES is a supported, tested deployment shape (runtime's notifications.hono integration test), and there no key can exist, so 'no key' is the true answer. Under A, every key-bearing request on a first boot would answer 503. Long-term: one boundary for the authz store, the one the 2026-08-30 ruling signed for tryFind. AI error-proofing: no second classifier; the shared predicate is called with the read object, as the types callers gate requires. Startup scope: no new code, no new error code. A pin holds both directions, and a missing-table phrase naming another relation stays loud."
        },
        {
          "question": "Settled: which object label does the outage carry?",
          "options": ["A: 'sys_api_key', the table whose read failed, as tryFind labels its reads (chosen)", "B: a source label such as 'api_key', as the policy and posture sources use 'api_key_policy' and 'auth_gate'"],
          "recommendation": "A. The constructor's field is documented as 'the object/table whose read failed', and this is a table read rather than a service source. No reader branches on the value. No new error code either way."
        },
        {
          "question": "For the seat: the triage grade (6249028135) asked for a re-raise if the cookie reading admitted a request as a principal it did not lead with. Measured: it did, on the REST data door and the runtime dispatcher. The REST pin's 'never served as the cookie's user' case and the settings pin's 'session read 0 times' case went red under the ablation.",
          "options": ["A: re-raise the grade at this reading", "B: keep p2, since the PR closes the path"],
          "recommendation": "The seat's call, not this dev's. The shipped behaviour on main admits a key-led request as the cookie's user while the key store is down. A caller holding both credentials is already both principals, so the question is the severity of the identity confusion, not unauthorised access."
        }
      ],
      "out_of_scope_findings": [
        "carrier: none · noted in the PR's Acceptance notes, not filed. The storage family's ADR-0069 gate reader (storage-service-plugin.ts buildAuthGateSessionReader) calls resolveApiKeyAdmission directly and had the same fall-through on 'none': it went on to read the session's posture. This PR's throw reaches it, and its suite is green, so nothing is left open. Dedupe words: storage gate reader api key none session fall-through."
      ]
    }

    Generated by Claude Code

  5. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    ContributorAuthor

    Re-grade: p2 → p1 · target:v18 kept · not security · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-18T21:01Z. This is the re-raise triage 6249028135 named for the lane: "If that measurement shows a request admitted as a principal it did not lead with, the lane re-raises the grade at that reading."

    • The reading (dev report 6254998617, measured before the fix on d65826fa7c): with the sys_api_key read rejecting, a request that led with an API key and also carried a different user's session cookie was served as the cookie's user. On the REST data door it answered 200 with the read run as the cookie's user. The runtime dispatcher resolved its scope to that user. A key-only caller got 401.
    • Why p1: a request is admitted as a principal it did not lead with, on public doors that admit keys, whenever the key store is unreachable. The actions, their audit attribution and their permissions follow the wrong identity. That is the condition triage named.
    • Why not security: the caller already holds both credentials. The cookie alone would have admitted it as that user, so no access is gained beyond what the caller already holds. It is identity confusion, not elevation.
    • State: PR fix(core): a rejecting sys_api_key read is the store outage (503), not "no key", so a cookie beside the key never answers for it #24397 closes the path: a rejecting key read now answers 503 on every key-admitting door, key-plus-cookie included. A CONTRACT_REVIEW_TIER review is owed before the queue.

    Generated by Claude Code

  6. added
    priority:p1High: required for production / M2
    and removed on Oct 18, 2026
  7. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #24397 comment 6255313090 · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-18T21:23Z.


    Generated by Claude Code

  8. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-18T21:57Z

    Probe Reading
    Review CONTRACT_REVIEW_TIER record 6255307197, PASS, on the narrowing face, at head 3d826adf26. ACCEPT 6255313090. Not governed.
    Queue It entered the merge queue at 2026-10-18T21:25Z and left it merged at 2026-10-18T21:56Z. No gh-readonly-queue/main/pr-24397-* branch remains.
    origin/main 62e2b6d01e is on origin/main. It carries PR #24397's 6 files, +517 / −9.
    Card Closed completed through Fixes #24299; pm:dispatched is cleared. The grade is p1 (re-grade 6255097198), not security.

    What main now holds:

    • A rejecting sys_api_key read in resolveApiKeyAdmission throws the branded AuthzStoreUnavailableError. Every door that admits keys answers 503 SERVICE_UNAVAILABLE: REST, the runtime dispatcher, settings and the storage family, and the doors that re-raise by brand.
    • A key sent beside another user's session cookie is no longer served as the cookie's user.
    • A never-provisioned sys_api_key table still answers "no key", through the shared isMissingTableError boundary the 2026-08-30 ruling signed for tryFind.
    • The other "no key" exits and session-only callers are unchanged.

    The dev session session_01BXWZRDo9sKLCG7VehArSHR is archived.


    Generated by Claude Code

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

Metadata

Metadata

Labels

area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingdomain:enginepriority:p1High: required for production / M2target:v18

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions