You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 5d4f2aa
Browse filesBrowse the repository at this point in the historyBrowse files
fix(core)!: an API key authenticates only as an owner who exists (#30783)
Clause-②: yes (narrowing) — a request authenticated by an API key whose
owner has no user record is answered as a key that does not exist (the
generic `401 UNAUTHENTICATED`), at every door that accepts a key, and
never falls through to a session sent beside it. The widening half: the
exported `ApiKeyRefusalReason` union gains `'owner_absent'`.
Metadata-protocol: none
Skills-docs: updated here —
`content/docs/permissions/authentication.mdx` states the rule; the
dogfood pin `api-key-owner-exists.dogfood.test.ts` exercises it.
## 维护者速读
- 改了什么:API key 只能以"仍然存在的所有者"身份通过认证。所有者没有用户记录的 key,在所有接受 key 的入口(REST
数据读写、key 管理写入、MCP 等)一律按"不存在的 key"作答(401),不会回落到同一请求携带的会话;key 行本身不改写、不吊销。
- 为什么改:落实 maintainer ruling 2026-11-05 (security#1 decision card) 的选项
B。key 是铸造者本人的凭据,所有者不存在,key 就不代表任何人。
- 风险与回滚:不新增任何读取,复用封禁检查本来就读的那一行 `sys_user`;没有 `sys_user` 表的存储会拒绝所有
key(有意为之,changeset 已写明);依赖此类 key 的集成会收到 401;回滚即还原本 PR。
- 先看:`packages/core/src/security/api-key.ts`(准入处 owner 读取之后新增的
`owner_absent`
段)、`packages/core/src/security/api-key-banned-owner.test.ts`、`packages/qa/dogfood/test/api-key-owner-exists.dogfood.test.ts`。
- 两分钟验证:`pnpm --filter @objectstack/dogfood exec vitest run
test/api-key-owner-exists.dogfood.test.ts`,预期 3/3 通过。
- 需要你做的:无,席位落地。
## What changes
- **The rule.** An API key is a credential of the user who minted it, so
it authenticates only as an owner who exists. A key whose owner has no
user record is refused where a key becomes its owner
(`resolveApiKeyAdmission`, the shared key-admission seam in
`@objectstack/core` that every key-accepting door uses) as `refused`
with the new reason `owner_absent`. On the wire it gets the answer a key
that does not exist gets (`anonymousEntry` in
`resolve-authz-context.ts`): the same status, code, message and
`WWW-Authenticate`, on the REST data door (read and write), on the
key-management write door (`PATCH /api/v1/data/sys_api_key/{id}`) and on
the MCP door. A session sent beside the key never answers for it.
- **Order and cost.** The key's own lifecycle (revoked / expired /
inactive) still answers first with its located `403`. The owner question
comes next, before the ban, and uses the same owner read the ban check
already makes, so no read is added. A key whose owner exists is
unaffected.
- **Failure modes.** An owner read that throws is still the branded
store outage (`503`). A store with no `sys_user` table has no owner who
exists, so it refuses every API key. This is deliberate, and it is the
same answer as a table that holds no row for the owner.
- **What the operator sees.** Each refused request logs one server-side
line through the existing refusal logger, `[security] API key refused
(owner_absent)`, naming the key's row id and the owner id, so the key to
revoke is named. The caller is told nothing beyond the generic `401`.
- **Remedy for an affected integration.** Mint a replacement key while
signed in as an existing user, and revoke the orphan keys in **Setup →
API Keys**.
- Docs: `content/docs/permissions/authentication.mdx` (API key section)
states the rule.
- Changeset: `.changeset/api-key-owner-must-exist.md`
(`@objectstack/core`, patch, BREAKING narrowing banner, ADR-0087
`not-required (no-migration-prescription)`). Only `@objectstack/core`
source moves; every other package in the diff changes tests only.
## Tests
- `packages/core/src/security/api-key-banned-owner.test.ts` (26). Two
pins flip:
- "a key whose owner row is absent keeps today's answer" now reads "a
key whose owner does not exist is refused (owner_absent), with no
principal";
- "a store with no sys_user table holds no ban" now reads "a store with
no sys_user table has no owner who exists: the key is refused as
owner_absent".
New cases:
- the owner is read by id, as the system, once (the read the ban takes);
- a refusal writes nothing;
- the key's own lifecycle answers first;
- an owner read that throws is still `503`;
- control: the same key is admitted once its owner exists;
- `resolveAuthzContext` resolves the same context a made-up key resolves
and logs exactly one refusal line naming the key row and the owner;
- the refusal never falls through to a session sent beside the key;
- on a face that serves the guest, an absent owner's key and an unknown
key get the same guest entry.
- `packages/qa/dogfood/test/api-key-owner-exists.dogfood.test.ts` (3,
showcase + MCP).
- Control: while its owner exists, the key reads (200), writes (201) and
opens an MCP session (200), and its own key change is refused `403
PERMISSION_DENIED`, as every key-authenticated key change is, with the
key row unchanged.
- With the owner's user record absent and the key row left in place,
every door answers the key exactly as it answers a made-up key,
comparing status, code, message and `WWW-Authenticate`. The doors are
data read, data write, key change (`PATCH /data/sys_api_key/{id}`), MCP
initialize, and a read with an administrator session sent beside the
key. The refused write lands nothing, the key row is unchanged, and
another member's key keeps working.
- Control: once the owner's record is back, the same key is served again
and the key row is not revoked.
- Fake-engine fixtures that serve a key now also serve its owner's user
row, through each fake's own `where` filter. The rule is never loosened
inside a fake. This covers four core test files and 24 consumer test
files across `mcp`, `plugin-sharing`, `rest`, `runtime`,
`service-datasource`, `service-settings` and `service-storage`.
`packages/runtime/src/security/resolve-execution-context.test.ts` types
one fixture's table map. That repairs a recorded index-signature error,
and the masked implicit-any it hid, so
`packages/runtime/test-typecheck-debt.json` is re-recorded down by both.
## Acceptance notes
- **Premise check.** The one published mint path, `POST /keys`
(`packages/runtime/src/domains/keys.ts`), writes `user_id` from the
signed-in session caller and requires a session credential. No seed or
dataset writes `sys_api_key`. So no shipped path mints a key for a user
who does not exist.
- **Key change door, before this change.** A key-authenticated write to
`sys_api_key` was already refused (`403 PERMISSION_DENIED`) for every
key credential. The new pin shows that such a key now gets the made-up
key's `401` there, before that check is reached.
- **A key row that names no owner at all** (an empty `user_id`) still
resolves as `none`, unchanged.
- **A refused key with a session sent beside it answers `401`**, because
the refused arm never consults the session. A made-up key with a session
beside it falls through to the session. This is the existing refused-arm
design, the same as for a banned owner's key today.
## Verification
Head `c534ccc5e1`: the change, with `main` merged in at `9b24f3ca87` (a
clean merge commit, no conflict). The merge changes nothing under
`packages/core`, `packages/mcp`, `service-datasource` or
`service-storage`. `packages/core/src/security/api-key.ts` has the same
blob (`10cc26fd83fc`) at this head, at `d6d9728906` and at `f45f9c0083`,
where the ablation was read. Every reading below was taken at
`c534ccc5e1` after a full `pnpm build` of the merged tree (`Tasks: 118
successful, 118 total`, under the verify lock), unless it names another
head.
<details><summary>Dogfood battery</summary>
At `c534ccc5e1`: `pnpm --filter @objectstack/dogfood test` under the
verify lock on a shared box, with no shard and no path, after the full
build. Verdict `command-exit 0`.
- Test Files 424 passed | 3 skipped (427)
- Tests 3535 passed | 41 skipped (3576)
- No failures. None of `api-key-owner-exists.dogfood.test.ts`,
`mcp-stdio-package-object-discovery.test.ts` or
`mcp-stdio-runtime-object-exposure.test.ts` carries a skip guard, so all
three are among the passed files.
Earlier head: at `8444aac000` the battery had two red MCP stdio files
(`mcp-stdio-package-object-discovery.test.ts`,
`mcp-stdio-runtime-object-exposure.test.ts`). Each fake engine answered
the key row but not its owner row. `d6d9728906` makes each fake answer
the key's owner row, filtered by the id the read asks for, and they are
green above.
</details>
<details><summary>Typecheck of the consumer packages</summary>
At `c534ccc5e1`, under the verify lock, each `pnpm --filter PKG run
typecheck`, with its script echoed in the log. Where the package's
script declares them, this includes `check:test-typecheck` and the
scripts/examples programs.
- `@objectstack/core`: exit 0
- `@objectstack/runtime`: exit 0
- `@objectstack/rest`: exit 0
- `@objectstack/mcp`: exit 0
- `@objectstack/plugin-sharing`: exit 0
- `@objectstack/service-datasource`: exit 0
- `@objectstack/service-settings`: exit 0
- `@objectstack/service-storage`: exit 0
- `@objectstack/dogfood` (`--max-old-space-size=8192`): exit 0
9 of 9 exit 0.
</details>
<details><summary>Gates</summary>
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at `c534ccc5e1` derived 98 commands against
merge base `9b24f3ca8` (37 paths, +517 / -50). Each was run on its own
with stdin closed, and its exit code was recorded before any pipe. All
98 exit 0.
- `check:type-check-debt` ran under the verify lock: 0 raw tsc errors,
none above its record.
- `--ran`: 98 derived, 98 run, 0 NOT-MEASURED, 0 UNRUN.
</details>
<details><summary>Package suites and CI</summary>
- CI at `c534ccc5e1`: 65 check-runs, 57 success, 8 skipped, 0 failure.
The required contexts `Lint & Repo Gates`, `TypeScript Type Check`,
`Test Core` (8/8 shards), `Build Core`, `Governed Surface Queue Guard`,
`Type Check UI`, `Test UI` (16/16), `Build & E2E UI` and `Live E2E UI`
are success. `Lint UI` is skipped.
- Local package suites were not re-run at this head. NOT MEASURED:
`rest`, `plugin-sharing`, `service-settings` and `runtime` (the packages
the merge touched). Reason: three verify-lock attempts got no turn
(queue-timeout, exit 99), behind another run that held the lock for over
27 minutes. `Test Core` above runs their full suites at this head.
- Local suites at earlier heads, from before this merge: `core` 142/142
files, 3233 tests, and `runtime` 549/549 files, 9949 passed, 19 skipped,
both at `8444aac000`. Also `rest` 395/395, `mcp` 56/56, `plugin-sharing`
67/67, `service-datasource` 72/72, `service-settings` 70/70 and
`service-storage` 73/73 test files. The `core`, `mcp`,
`service-datasource` and `service-storage` sources are unchanged by the
merge.
- Packages that read API keys through fakes but whose tests did not
change (`cloud-connection`, `plugin-auth`, `plugin-hono-server`,
`organizations`, `plugin-approvals`, `plugin-audit`, `plugin-security`,
`plugin-webhooks`, `service-automation`, `trigger-schedule`, `client`):
their key-fixture files passed at an earlier head of this branch. `Test
Core` runs their full suites at this head.
- Test budget: `api-key-owner-exists.dogfood.test.ts` measured 7.17 s
and 7.27 s module duration (two local runs, shared box). That is under
the 10 s line, so the file declares no budget.
</details>
<details><summary>Ablation (one-shot, not committed)</summary>
Read at `f45f9c0083`. `api-key.ts` has the same blob at this head.
Mutation `if ((!owner || typeof owner !== 'object') && (owner as
any)?.__ablation_marker_r10 === 'never') {` through
`scripts/ablation-replace.mjs` (anchor 1 to 0, blob `10cc26fd83fc` to
`5d35544ec77d`); `pnpm --filter @objectstack/core build`;
`ablation-dist-preflight.mjs @objectstack/core __ablation_marker_r10`:
marker present in 2 built files.
- unit `api-key-banned-owner.test.ts`: `Tests 7 failed | 19 passed
(26)`, the seven being the owner-absent pins
- dogfood `api-key-owner-exists.dogfood.test.ts`: `Tests 1 failed | 2
passed (3)` (the door case: `data read: expected { status: 200 … } to
deeply equal { status: 401 … }`)
Restore: blob after restore `10cc26fd83fc` equals HEAD and `git diff
HEAD` is empty. Core was rebuilt, and the `--absent` preflight read the
marker absent from all 14 built files, with the tree clean.
</details>
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01L6bhgVfjk2i8T2D4RmxsKg
<!-- signed: security#1 · os-tesla · session_01L9qibvynRbCtMndW8EHNGc
-->
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(core)!: an API key authenticates only as an owner who exists
6
+
7
+
Clause-②: yes (narrowing) — a request authenticated by an API key whose owner has no user record is answered as a key that does not exist (the generic `401 UNAUTHENTICATED`), at every door that accepts a key, and never falls through to a session sent beside it. The widening half: the exported `ApiKeyRefusalReason` union gains `'owner_absent'`.
8
+
Metadata-protocol: none
9
+
Skills-docs: updated here — `content/docs/permissions/authentication.mdx` states the rule; the dogfood pin `api-key-owner-exists.dogfood.test.ts` exercises it.
10
+
11
+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped and nothing at rest changes: the owner row the key admission already reads decides it, and no key row is revoked or rewritten. -->
12
+
13
+
**BREAKING** accept-set narrowing at every key-admitting door (`@objectstack/core`): an API key authenticates only as an owner who exists.
14
+
15
+
-**The rule.** An API key is a credential of the user who minted it. A key whose owner has no user record is a credential of no one: it is refused where a key becomes its owner (the shared key-admission seam), with the answer a key that does not exist gets, on the REST data door (read and write), the MCP door and every other door that resolves a key. It never falls through to a session sent beside it.
16
+
-**A store with no `sys_user` table refuses every API key.** No owner exists in such a store, so no key authenticates there. This is deliberate, and it is the same answer as a store whose `sys_user` table holds no row for the key's owner.
17
+
-**What the operator sees.** Each refused request logs one server-side line, `[security] API key refused (owner_absent)`, naming the key's row id and the owner id, so the key to revoke is named. The caller is told nothing beyond the generic `401`.
18
+
-**Unchanged.** A key's own lifecycle still answers first: a revoked, expired or inactive key keeps its located `403`. An owner read that fails still answers `503`. A key whose owner exists is unaffected, and no read is added: the decision uses the owner read the ban check already makes.
19
+
-**Who moves.** An integration running on a key whose owner has no user record gets `401`.
20
+
21
+
**The remedy.** Mint a replacement key while signed in as an existing user, and revoke the orphan keys in **Setup → API Keys**.
0 commit comments