Skip to content

harden(data): changing an API key through the data door requires a signed-in session - #27692

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/harden-api-key-data-door
Oct 29, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/harden-api-key-data-door

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk

Clause-②: yes (narrowing)
Metadata-protocol: none
Skills-docs: none — no file under skills/** names sys_api_key or teaches managing a key with an API key, and the docs teach key revocation and rotation from the API Keys list (content/docs/ai/connect-mcp.mdx, content/docs/api/error-catalog.mdx), a signed-in surface; every taught use stays true.

维护者速读

  • 改了什么:所有数据入口(REST /api/v1/data/sys_api_key 及批量与跨对象 /batch、运行时调度器的 /data 与 /mcp HTTP 工具与动作入口、stdio MCP 的写工具)上,凡由 API Key 认证的调用方对 sys_api_key 的写入,一律拒绝 403 PERMISSION_DENIED,什么都不写;读取不变。与 /api/v1/keys 的「签发 Key 需要登录会话」是同一条规则(该规则已随 main 合入本分支)。
  • 为什么改:QA 第三轮发现的安全加固(细节按披露裁决不公开)
  • 风险与回滚:收窄(BREAKING):只影响用 API Key 去改 sys_api_key 的脚本或代理;登录会话、系统上下文写入(签发、last_used_at 戳、强制重置)不变,未暴露的操作仍先回 405。@objectstack/core 入口新增四个导出(故声明 yes)。回滚 = revert 本 PR,无数据迁移。
  • 先看:① packages/core/src/security/api-key-management-credential.ts(规则本身,credential !== 'api_key' 一行决定正向判定);② packages/mcp/src/stdio-data-bridge.ts 三个写分支新增的一行与 enforceApiKeyManagementCredential;③ packages/rest/src/rest-server.ts 的 enforceApiAccess 新增段与 computeExecCtx 的 onAdmission 捕获。
  • 两分钟验证:pnpm --filter @objectstack/mcp exec vitest run src/stdio-data-bridge.exposure.test.ts,预期 32/32 通过(其中 5 个为本 PR 的 stdio 用例:写入 403 PERMISSION_DENIED 且引擎未被调用,未暴露操作仍 405,读取与其他对象照常)。
  • 需要你做的:无,席位落地。

What changes

Managing API keys is a signed-in-session act on every data door, as minting one through POST /api/v1/keys is. A write to sys_api_key from a caller an API key authenticated is now refused 403 PERMISSION_DENIED (ADR-0112 envelope: code, status, object), and nothing is written. The message names the road: sign in and manage the key from the API Keys page.

  • One rule — apiKeyManagementCredentialRefusal, added to @objectstack/core's entry with API_KEY_MANAGEMENT_READ_OPERATIONS, API_KEY_MANAGEMENT_SESSION_REQUIRED_MESSAGE and the type ApiKeyManagementCredentialInput (four new public names, hence Clause-②: yes). Positive on the credential: refused only when the door knows an API key admitted the caller. Every operation that is not a read (get, list, query, find, aggregate, history, export) is a write for this rule, so a verb a door adds later is covered from the day it lands.
  • Asked beside each door's exposure gate, after the exposure verdict (an operation the object does not expose keeps its 405):
    • RestServer.enforceApiAccess — every single-object data route of the server. The route hands its own resolved context in (a required parameter, so the type checker holds every call site to it), and computeExecCtx keeps the credential the shared resolver reports through onAdmission as the internal __admittedBy key, beside __authEnvironmentId and __environmentId. The cross-object POST /batch asks the rule per op, after its own per-op exposure check.
    • The runtime dispatcher's refuseUnexposedDataAction — reached by /data and by callData, so the /mcp HTTP tools and the action doors too. The credential is HttpProtocolContext.admission, which the identity step writes.
    • The stdio MCP transport's write arms (create, update, remove in stdio-data-bridge.ts, the create_record / update_record / delete_record tools). That transport admits its principal from OS_MCP_STDIO_API_KEY alone (ADR-0101), so the credential is the API key by construction; the refusal is thrown like the exposure refusals beside it, before the engine is asked.
  • Unchanged: session callers; reads on every door; system-context writes (the mint, the key-admission last_used_at stamp, the forced reset); the ExecutionContext shape (no spec type widened); a dispatcher context with no admission (embedders calling the dispatcher's handlers directly).

Tests

  • packages/core/src/security/api-key-management-credential.test.ts — the rule: refused writes on the envelope, unknown verb refused, reads / session / no credential / other objects left alone.
  • packages/rest/src/rest-api-key-management-credential.test.ts — RestServer: PATCH refused with the protocol never asked; session and no-credential served with the patch they sent; reads and other objects served; 405 ahead of the rule; per-object bulk and cross-object batch refused before the protocol or the transaction is asked.
  • packages/runtime/src/domains/data-api-key-management-credential.test.ts — dispatcher: /data PATCH and callData refused (rendered through the dispatcher's own errorFromThrown), session and no-admission served, system context untouched, reads and other objects served.
  • packages/mcp/src/stdio-data-bridge.exposure.test.ts (new block, on the file's existing doubles) — stdio: update refused on the shipped declaration and create / update / remove refused on an unrestricted one, each with the engine never asked; the shipped declaration's 405 ahead of the rule; reads of sys_api_key served; a write to another object served.
  • packages/qa/dogfood/test/api-key-data-door-session-required.dogfood.test.ts — the HTTP door on the booted showcase, as a plain member, x-api-key only: each refused write asserts status, code and the row afterwards; controls: the session revokes and restores from the list (200), a key revoked with the forced-reset reason stays administrator-only for the owner's session (403, existing rule), the mint and the last_used_at stamp still land.
  • Pins moved with the new internal key: rest-exec-ctx-principal-kind.test.ts (golden key set gains __admittedBy, asserted 'session'), request-execution-context.conformance.test.ts (REST's drive sets the key aside and asserts it per request).

Acceptance notes

  • Reads of sys_api_key by an API-key-authenticated caller stay open on every door (seat decision, option A): the owner's own rows under the existing row scope, the stored hash never served.
  • The dispatcher's twin is pinned at the unit layer: in the composed showcase stack RestServer serves /api/v1/data, so the dogfood file exercises that door. The stdio transport is pinned at the bridge, where its write arms live.
  • On the dogfood name case: the column whitelist already answered it 403 PERMISSION_DENIED, so that case holds the row unchanged but is not evidence for the new rule (the ablation below leaves it green, as expected).
  • The stdio check reads no elevation flag: the transport's principal is never the system context, so there is no bypass to declare on the system-context page.
  • origin/main is merged in (a9befcfe), so the POST /api/v1/keys session rule this one mirrors is on this tree.

Verification

Final head 853277e1 (objectstack-ai/objectstack; origin/main merged at a9befcfe).

Package suites and type checks (all at 853277e)
  • @objectstack/core test: 127/127 files, 2868/2868 tests. typecheck exit 0 (check:test-typecheck: OK, 4 pinned signatures held).
  • @objectstack/rest test: 341/341 files, 6673 passed, 326 skipped. typecheck exit 0 (0 pinned).
  • @objectstack/runtime test: 478/478 files, 7790 passed, 19 skipped. typecheck exit 0 (68 pinned signatures held).
  • @objectstack/mcp test: 45/45 files, 586/586 tests. typecheck exit 0 (8 pinned signatures held).
  • eslint --no-inline-config over every touched source and test file: exit 0.
Ablations (one-shot, restored to the HEAD blob)

The rule (at abe793d0; @objectstack/core source unchanged since apart from its docblock): the credential test in apiKeyManagementCredentialRefusal made always-true for "not applicable", core rebuilt, ablation-dist-preflight marker present in 2 built files. Core 6 failed / 14 passed, runtime 2 / 4, dogfood 3 / 6 (the three revoked refusals red; name green because the column whitelist refuses it first; controls green). Restored to the HEAD blob, --absent marker absent from all 14 built files, tree clean, the three files green again.

The stdio check (at 853277e1): through scripts/ablation-replace.mjs (WRAP mode, anchor hit x1, blob 6aaa0d9e5074 to 61c98e653613) the stdio helper's refusal made never to throw. The bridge test imports the bridge from source, so no rebuild is in its path. stdio-data-bridge.exposure.test.ts: 2 failed / 30 passed (the two refusal cases red; 405, reads and the other-object case green). Restore: blob == HEAD 6aaa0d9e5074, git diff HEAD empty, whole-tree git status --porcelain empty.

Dogfood battery (declared narrowing)

pnpm --filter @objectstack/dogfood test under the verify lock at 853277e1: exit 0; Test Files 353 passed, 3 skipped (356); Tests 2853 passed, 20 skipped (2873).

Derived gates and declarations

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 853277e1: 68 commands, each run with its exit captured before any pipe, all exit 0; plus check:changeset-fixed, check:error-code-casing, check:filter-alias-parity, check:route-ledger-census and check:doc-anchors, all exit 0. Two families first read NOT MEASURED (exit 3) and were re-run to exit 0: check:dual-build-cjs-loads (fresh worktree, unrelated packages lacked dist/) and check:type-check-debt (its lock was held by the battery). --ran reconciliation: 68 derived, 68 run, 0 NOT MEASURED, 0 UNRUN.

check:system-context-census, red on c9771407 (a new elevation read in the stdio helper), is exit 0 on 853277e1: the read is gone. check:doc-anchors was green on both heads.

pnpm check:adr-0087-registration: exit 0 (one breaking changeset, [BREAKING+clause-②-narrowing], not-required (no-migration-prescription)). node scripts/check-changeset-no-major.mjs --base origin/main: exit 0; with --event over this body: exit 0 (LEVEL AXIS: this PR declares clause-② yes (narrowing) and metadata-protocol none … a non-protocol change at patch).

The required CI contexts are this PR's check-runs; the seat reads them.


Generated by Claude Code

…gned-in session

A write to sys_api_key through the generic data doors (RestServer and the
runtime dispatcher's data faces) from a request an API key authenticated is
refused 403 PERMISSION_DENIED; reads, session callers and system-context
writes are unchanged. One rule in @objectstack/core, asked at each door's
per-object gate.

Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk
Co-authored-by: Claude <noreply@anthropic.com>
…e per op on the cross-object batch

The credential rule reads the route's own resolved context instead of a
second resolve, and the cross-object batch asks it per op after its
exposure verdict. Pins the fourth post-assembly internal key.

Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk
Co-authored-by: Claude <noreply@anthropic.com>
…e one-context conformance

Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 29, 2026
@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/core, @objectstack/mcp, @objectstack/rest, @objectstack/runtime, touching 18 documentable anchor(s).

18 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json a75ddba178a9196c87e60025409160a40322fac1.

⛔ 3 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 cross-cutting symbol(s) contributed no route anchor: enforceApiAccess (10 routes)
  • 1 anchor(s) matched too much of the corpus to be a work list: PERMISSION_DENIED (literal, 49 pages)
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 58 of 210 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 66 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json a75ddba178a9196c87e60025409160a40322fac1 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from ec4a200aa41771524f3a7c7095df404d3f6f009d — the merge of head 853277e1dfa7f78ad3c249d32bede7e0a9f30240 into base a75ddba178a9196c87e60025409160a40322fac1, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin ec4a200aa41771524f3a7c7095df404d3f6f009d && git checkout ec4a200aa41771524f3a7c7095df404d3f6f009d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a75ddba178a9196c87e60025409160a40322fac1 853277e1dfa7f78ad3c249d32bede7e0a9f30240 && git checkout -B drift-repro a75ddba178a9196c87e60025409160a40322fac1 && git merge --no-ff 853277e1dfa7f78ad3c249d32bede7e0a9f30240

node scripts/docs-audit/affected-docs.mjs --json a75ddba178a9196c87e60025409160a40322fac1

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs a75ddba178a9196c87e60025409160a40322fac1 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: abe793d01950fbaeeb3ea3a96177d660c1fbed1f
Local-runs: none
Scope: packages/core/src/security/api-key-management-credential.ts, packages/core/src/security/index.ts, packages/core/src/security/api-key-management-credential.test.ts, packages/rest/src/rest-server.ts, packages/rest/src/batch-routes.ts, packages/rest/src/crud-routes.ts, packages/rest/src/data-action-routes.ts, packages/rest/src/rest-api-key-management-credential.test.ts, packages/rest/src/rest-exec-ctx-principal-kind.test.ts, packages/runtime/src/action-execution.ts, packages/runtime/src/domains/data-api-key-management-credential.test.ts, packages/runtime/src/security/request-execution-context.conformance.test.ts, packages/qa/dogfood/test/api-key-data-door-session-required.dogfood.test.ts + packages/core/src/index.ts (export * from './security/index.js'), packages/core/package.json (exports), packages/core/src/security/request-execution-context.ts (RequestAdmission, the onAdmission call), packages/rest/src/rest-server.ts (computeExecCtx, resolveExecCtx, apiAccessDenialFromEnable, mapDataError), packages/rest/src/index.ts, packages/rest/src/{sharing,approvals,external-datasource}-routes.ts and import-template.ts (the other /data mounts), packages/runtime/src/http-dispatcher.ts (the identity step's onAdmission, HttpProtocolContext.admission), packages/runtime/src/domains/data.ts and domains/mcp.ts (buildMcpBridge), packages/runtime/src/domains/keys.ts on this head and on main, packages/mcp/src/plugin.ts (resolveStdioExecutionContext) and packages/mcp/src/stdio-data-bridge.ts (GATED_ACTIONS, create, update, remove), packages/platform-objects/src/identity/sys-api-key.object.ts (apiMethods), packages/spec/src/system/constants/system-names.ts, packages/spec/src/kernel/execution-context.zod.ts, packages/core/CHANGELOG.md (the declaration precedents), scripts/check-changeset-no-major.mjs (the declaration prompt), review of record 6495005521 on #27477 (item 15).

Face: narrowing, declared. The diff touches no packages/spec/src/** path and no governed path; the floor on this face is every path of the diff outside the prose faces, and the changeset is read for ② alone. No card (disclosure ruling); the lane seat's ACCEPT covers the prose.

Security-hardening ruling: this record names positions and refusals; it carries no reproduction.

① Derived judgments

The accept set and the public surface the diff changes, read off the code on this head:

  1. The rule. apiKeyManagementCredentialRefusal({ object, operation, credential }) answers a located 403 PERMISSION_DENIED (code, status, statusCode, object) only when credential === 'api_key', object === SystemObjectName.API_KEY and the operation is outside the closed read set (get, list, query, find, aggregate, history, export); undefined otherwise. Positive on the credential, so a context told nothing keeps today's answer; every unknown verb is a write. Correct, and the one definition both doors import.
  2. REST, single-object routes. enforceApiAccess asks the rule after apiAccessDenialFromEnable and before returning false, so the exposure verdict (405 OBJECT_API_METHOD_NOT_ALLOWED, 404 OBJECT_API_DISABLED) comes first. Every caller of the gate hands the route's own context in: crud-routes (list, get, create, list, update, delete), data-action-routes (clone as create, field-access as get, the four import doors, export), the four per-object bulk routes (bulk with its child), and rest-server.ts's own import gate. The parameter is required, so the type checker holds every call site; the grep over packages/rest/src finds no caller without it. mapDataError renders the refusal on the data envelope. Correct.
  3. REST, cross-object POST /batch. After the per-op exposure loop (bulk ∧ child) and before the transaction opens, each op is asked the rule with operation: 'bulk'; handleRouteError renders it. Pinned: a batch naming sys_api_key is refused with the transaction never opened, and one that does not is served. Correct.
  4. The credential on REST. computeExecCtx passes onAdmission to resolveRequestExecutionContext, which fires it only when authz.userId && authz.credential ('api_key' | 'session', the RequestAdmission type), and stamps the value as __admittedBy beside __kernel, __authEnvironmentId and __environmentId, under the same as any cast, outside ExecutionContext. resolveExecCtx is memoized per request and is the one producer every data route reads (sharing, approvals, analytics and batch routes read the same method; no composition overrides it outside tests). An anonymous or key-refused request fires no admission and the rule stays silent, which is right: those are not API-key-admitted callers. Correct.
  5. The internal key and the two moved pins. __admittedBy has exactly the status of __authEnvironmentId: an authorization input at one named reader (enforceApiAccess), not an ExecutionContext field, nothing downstream branches on it. The golden key set in rest-exec-ctx-principal-kind.test.ts gains it as the fourth post-assembly key and asserts 'session' on that fixture; request-execution-context.conformance.test.ts sets it aside by name in driveRest and asserts 'api_key' when the request led with x-api-key and 'session' otherwise, and its REST_ROW keeps onAdmission: undefined because the callback shapes no field of the context. Both moves are the right ones; the conformance test's purpose (one composition, by name) is preserved. Correct.
  6. The runtime dispatcher. refuseUnexposedDataAction returns at once for executionContext?.isSystem, judges exposure (checkApiExposure, the disabled-package refusal on the 'refuse' arm), and then asks the rule off requestContext.admission.credential, which the dispatcher's identity step writes through the same onAdmission (http-dispatcher.ts). Reached by handleDataRequest (create, get, query, update) and by callData (query, get, aggregate, create, update, delete from buildMcpBridge, bound to the request's own context; the action doors and the endpoint executor too). Every read verb a caller of this gate passes is in the rule's read set; every write verb is outside it. Pinned: /data PATCH and callData refused with the protocol never asked and the dispatcher's own errorFromThrown rendering 403; session, no-admission and system contexts served; reads and other objects served. Correct.
  7. Exposure first, everywhere. sys_api_key declares apiMethods: ['get', 'list', 'update'], so create, delete, bulk and import answer 405 on every door before the rule is asked; the dogfood file pins POST and DELETE at 405 OBJECT_API_METHOD_NOT_ALLOWED, the REST unit test pins a narrower declaration at 405 ahead of the rule, and the dispatcher test's fixture reaches the rule through the fall-open declaration. The one write that is exposed, update, is the one the rule refuses. Correct.
  8. System-context writes. The mint (domains/keys.ts, ql.insert as the system), the key-admission last_used_at stamp (api-key.ts, ql.update as the system) and the forced reset's revoke never pass a door's gate as a caller; the dogfood control pins both the mint and the stamp landing after the change. Correct.
  9. The name case. The ADR-0092 D2 column whitelist (revoked alone) already refuses a member's name patch with the same code, so that dogfood case holds the row but is not evidence for this rule; the body says so and the ablation left it green. Judged honestly.
  10. Other /data mounts. sharing-routes (/:id/shares), approvals-routes, external-datasource-routes (/datasources/:name/external), import-template (export?template=true) and query-allowlist write no row of the addressed object; none is a door to sys_api_key. Correct, no gap there.
  11. The stdio MCP door is not covered. packages/mcp's stdio transport resolves OS_MCP_STDIO_API_KEY through resolveAuthzContext({ headers: { 'x-api-key': apiKey } }) into an ExecutionContext by assembleExecutionContext (plugin.ts, resolveStdioExecutionContext): every caller of that door is admitted by an API key, by construction. Its bridge (stdio-data-bridge.ts) does not run through callData; by its own header it mirrors callData's exposure gate (enforceApiExposure, GATED_ACTIONS) and writes through the engine directly (engine.insert in create, engine.update(object, data, { where: { id }, context }) in update, the same in remove). The ExecutionContext envelope carries no credential (execution-context.zod.ts names none), so nothing on that door can ask the rule, and nothing does: an update_record on sys_api_key (an exposed verb, item 7) from the stdio tool is served to an API-key-admitted principal exactly as before. The changeset's TO ("a write to sys_api_key from a request an API key authenticated is refused … on every data door"), the module docblock ("on every data door") and the body's "every user-facing door" are therefore not true of the tree. Must-fix 2.
  12. POST /api/v1/keys on this tree. The branch's merge base is 4278c3b5, 37 commits behind origin/main, and 79bf748ee (harden(keys): minting an API key requires an interactive session #27536, the mint's interactive-session rule) is not an ancestor of it: domains/keys.ts on this head carries no admission.credential !== 'session' refusal, while main's does. The docblock, the changeset and the body state the mint rule as the premise of this one; on the tree as reviewed the premise lives on main only. The queue merges main in, and nothing in this diff touches keys.ts, so no conflict; the sentences are true of the landed tree and not of this head. Should-fix 1.
  13. Public surface. @objectstack/core's entry does export * from './security/index.js', and security/index.ts now re-exports apiKeyManagementCredentialRefusal, API_KEY_MANAGEMENT_READ_OPERATIONS, API_KEY_MANAGEMENT_SESSION_REQUIRED_MESSAGE and the type ApiKeyManagementCredentialInput: four new names on the package's published entry (exports: ".", "./logger"). That is a widened public surface; see ②. RestServer.enforceApiAccess is private; its new required context parameter is read by the route hosts through RestServer['enforceApiAccess'] (an internal pick on CrudRouteHost, DataActionRouteHost, BatchRouteHost), and packages/rest/src/index.ts exports the class, not the method. A private method's signature is not a published accept set, and the body is right that this part owes no declaration. ExecutionContext and packages/spec are unchanged; PERMISSION_DENIED is an existing ledger code; no route is added. Correct.
  14. Message. The refusal names the road (sign in, the API Keys page) and nothing about the key presented. Correct.

② Semver level

Declared on the changeset and the body: Clause-②: no (narrowing), Metadata-protocol: none; @objectstack/core, @objectstack/rest, @objectstack/runtime at patch; Skills-docs: none — REASON with a measured reading (no skills/** file names sys_api_key; the two skill mentions of "API key" are the secret field type).

The no is wrong; the arm is right. The diff widens the public surface of @objectstack/core by four names reachable from the package entry (① item 13), and the clause asks exactly that (「本卡放宽接受集或扩大公开面吗」; the gate's own prompt: "Clause-②: yes — it widens one"). The fleet's practice on this package is consistent: an added core export is declared yes — yes (widening) for orderHydrationBatch / declaredDependencyIds, isCloudManaged and the position-grant readers, and yes (narrowing) where a narrowing rides with new exports (the app-automation elevation mark, the Default-Organization definition, directPermissions), all in packages/core/CHANGELOG.md; the review of record on #27477 judged no right there only because that PR's new functions were module exports not re-exported from the entry. The ADR-0087 marker already states "one export is added to @objectstack/core", so the changeset contradicts its own declaration line. Fix: Clause-②: yes (narrowing) on the changeset and the body; Metadata-protocol: none stays right (no authorable key, value or type moves), the three patch grades stay right under ruling A, and the no-major gate reads yes as a widening label with no level effect. Must-fix 1.

The rest of the declaration holds: the **BREAKING** (an accept-set narrowing) banner; a FROM / TO naming the narrowed request (a sys_api_key write from an API-key-authenticated request on the generic data door), who is affected, what does not change (session, reads, system-context writes, the 405), where the rule is decided and the embedder case; exactly one ADR-0087 HTML-comment marker in not-required (no-migration-prescription); and the Skills-docs: line. The TO's "every data door" and the module docblock's same phrase are corrected by must-fix 2, whichever way it is resolved. Check Changeset concluded success on this head.

③ Boundary flags

  • (a) Dev's open question: reads of sys_api_key by an API-key-authenticated request stay open (option A). No reason against from this review: a key reads only its owner's rows under the existing row scope, the stored hash is omitted on every read surface (api-key-ui-lifecycle (secondary): the key column (SHA-256 hash) serializes over the data API, contradicting its own "never exposed to clients" description #7728), and listing the owner's keys is what the API Keys list itself does; the writes are what manage a credential, and they are the two rules' subject (this one and the restore guard). A later decision can tighten reads without touching this rule's shape, since the read set is a closed list in one place.
  • (b) "The dispatcher's twin is pinned at the unit layer; the composed showcase serves /api/v1/data through RestServer." Accepted: the dogfood file exercises the door the composition mounts, and the dispatcher's refusal is pinned through its own exits.
  • (c) The name case is not evidence for the rule (① item 9). Accepted as stated.
  • (d) The mint-rule premise (harden(keys): minting an API key requires an interactive session #27536) is on main, not on this head (① item 12). Should-fix 1: merge origin/main so the tree the sentences describe is the tree reviewed and the battery runs on the combined tree; the body's Verification then names that head.
  • (e) Checks on abe793d0 at review time: Check Changeset, Governed Surface Queue Guard, Dogfood Regression Gate, PR-body guards, Lint · PM dispatch-gates, Lint · scoped · tooling self-tests, Spec property liveness and the labelling runs concluded success (24 distinct); Build Core, the eight Test Core shards, the Type Check lanes, Type Check UI and Build & E2E UI were in_progress; nothing had failed. The landing pre-check (全绿) is the seat's read at landing, not this record's.
  • (f) open_questions: (a) alone. Author is objectstack-fleet[bot]; no login note owed.

④ Design judgment

On the narrowing face, against the published spec on main: the change takes no declared capability away at the consumer and makes an existing posture real. sys_api_key is managedBy: 'better-auth', mints only through POST /api/v1/keys, and exposes update on the data door solely so the owner's own list can revoke and restore (ADR-0092 D4, the D2 column whitelist); with #27536 the mint is a signed-in act, and a credential-lifecycle act reachable with the credential itself is the gap this closes. The shape is right: one rule in @objectstack/core, positive on the credential, asked at the per-object gate every route of each door already passes, after the exposure verdict so 405 keeps its place, rendered on the ADR-0112 envelope the doors already map, with the credential carried as an internal door key rather than a widened ExecutionContext (the right call: the envelope is a published hook surface, and this is a fact about how the context was obtained). The REST and dispatcher halves cannot drift because both import the one function and both pass their own verb vocabulary into the one read set.

What does not hold is the coverage claim. The rule is placed "where every data door passes", and the two doors named are two of three: the stdio MCP transport is a data door whose only credential is an API key and whose bridge gates exposure beside, not through, callData (① item 11). Either the rule is asked there too — beside enforceApiExposure in create, update and remove with credential: 'api_key', which that door knows by construction, plus a pin in packages/mcp — or the seat rules that transport out (an operator-configured local process rather than "a request") and the changeset, the module docblock and the body stop saying "every data door" and name it as unchanged. The first is the design this PR states; the second is a different, narrower contract. Both are must-fix 2; the record does not choose. Judged per hits file:

  • api-key-management-credential.ts: the rule as read; the docblock's "every data door" is corrected by must-fix 2; holds otherwise.
  • security/index.ts: four public names; the declaration is must-fix 1; holds otherwise.
  • rest-server.ts: gate placement, required parameter, onAdmission capture, the internal key's docblock; holds.
  • batch-routes.ts, crud-routes.ts, data-action-routes.ts: every call site passes the context; the batch asks per op after exposure and before the transaction; holds.
  • action-execution.ts: the gate after exposure, the system-context return first, the credential off HttpProtocolContext.admission; holds.
  • The five test files: each refusal pins status, code and that the protocol or transaction was never asked, and each served case pins the arguments the protocol received; the dogfood file pins the row afterwards and the key's own admission before and after; the two moved pins keep their purpose; hold. Ablation evidence in the body (the rule's credential test made always-true: core 6 red, runtime 2 red, dogfood 3 red, controls green; restored and proven absent) is consistent with the pins read here; not re-run.

Must-fix

  1. Declaration: Clause-②: yes (narrowing) on the changeset and the PR body (② names the precedent spelling); keep Metadata-protocol: none, the three patch grades, the banner, the single ADR-0087 marker and the Skills-docs: line. Re-run pnpm check:adr-0087-registration and check-changeset-no-major locally.
  2. The stdio MCP door (① item 11, ④): ask apiKeyManagementCredentialRefusal in packages/mcp/src/stdio-data-bridge.ts beside enforceApiExposure on create, update and remove, with credential: 'api_key', rendered as that door renders its other refusals, and pin it in packages/mcp (refused with the engine never asked; a read served); add @objectstack/mcp to the changeset. Or, on a seat ruling that the stdio transport is outside this rule, strike "every data door" from the changeset's TO, the module docblock and the body, and name the stdio transport as unchanged in the TO's "What does not change".

Should-fix

  1. Merge origin/main (37 commits, 79bf748ee among them) so the head's domains/keys.ts carries the mint rule the docblock, the changeset and the body cite as this rule's premise, and re-read the battery and the Verification head on the merged tree.

Implemented-by: claude/harden-api-key-data-door
Reviewed-by: session_017xRBcVy4VXmkQjn2MGeaWk

VERDICT: FAIL

The refusal is correctly placed and complete on the two doors the PR names, the 405-before-403 order holds at all three gates, the internal key and the moved pins are right, the private enforceApiAccess signature owes no declaration, and option A on reads is sound; the FAIL is the no on a diff that adds four public exports (must-fix 1) and the uncovered stdio MCP door against a contract that says "every data door" (must-fix 2). A head that carries those two and nothing else owes a delta review by this reviewer, not a full round.

… write arms

The stdio bridge's create, update and remove ask
apiKeyManagementCredentialRefusal beside the exposure gate, with the
transport's API-key credential. The changeset declares the widened core
entry (Clause-② yes) and names the stdio door.

Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk
Co-authored-by: Claude <noreply@anthropic.com>
The stdio principal is never the system context, so the API-key
management check on its write arms asks the rule unconditionally.

Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 853277e1dfa7f78ad3c249d32bede7e0a9f30240
Local-runs: none
Scope: packages/core/src/security/api-key-management-credential.ts, packages/core/src/security/index.ts, packages/core/src/security/api-key-management-credential.test.ts, packages/mcp/src/stdio-data-bridge.ts, packages/mcp/src/stdio-data-bridge.exposure.test.ts, packages/rest/src/rest-server.ts, packages/rest/src/batch-routes.ts, packages/rest/src/crud-routes.ts, packages/rest/src/data-action-routes.ts, packages/rest/src/rest-api-key-management-credential.test.ts, packages/rest/src/rest-exec-ctx-principal-kind.test.ts, packages/runtime/src/action-execution.ts, packages/runtime/src/domains/data-api-key-management-credential.test.ts, packages/runtime/src/security/request-execution-context.conformance.test.ts, packages/qa/dogfood/test/api-key-data-door-session-required.dogfood.test.ts + the cited code named in record 6506196540 (unchanged on this head), packages/mcp/src/plugin.ts (resolveStdioExecutionContext), packages/core/src/security/assemble-execution-context.ts (isSystem: false), packages/runtime/src/domains/keys.ts on this head, docs/adr/0101-mcp-stdio-principal-admission.md, scripts/check-system-context-census.mjs, .changeset/harden-api-key-data-door-session.md (read for ②), the PR body as rewritten 2026-10-29T20:23Z.

Face: narrowing, declared. Delta review of record 6506196540 (FAIL on the declaration and the stdio MCP door, head abe793d0). The PR's own delta since that head is three commits on packages/mcp (c9771407, 9bb853f2, 853277e1) and one merge of origin/main (a9befcfe); git diff main...head on the PR's files shows core, rest, runtime and the dogfood file byte-identical to abe793d0 except the core module's docblock. Every ① and ④ judgment of the earlier record on those files stands by reference; this record judges the delta, the merge and the body.

Security-hardening ruling: this record names positions and refusals; it carries no reproduction.

① Derived judgments

  1. The stdio MCP door is now covered (must-fix 2, option 1). stdio-data-bridge.ts imports the one rule from @objectstack/core and asks it through enforceApiKeyManagementCredential(object, action) with credential: 'api_key' on each write arm — create (GATED_ACTIONS.create), update and remove — immediately after enforceApiExposure and before findById or any engine call, thrown as the exposure refusals beside it are thrown, so the tool layer renders it the same way. The credential is the API key by construction: resolveStdioExecutionContext admits the principal from OS_MCP_STDIO_API_KEY through resolveAuthzContext({ headers: { 'x-api-key' } }) and nothing else (ADR-0101 D1). Order holds: an unexposed verb keeps its 405 ahead of the rule. The read arms (query, get, aggregate) are untouched. Correct, and the same shape as the other two doors.
  2. The isSystem early return removed in 853277e1: right. assembleExecutionContext stamps isSystem: false on every context it builds, and the stdio principal is built by it from the key's owner (ADR-0101: "no system bypass"); resolvePrincipal() has no other producer. A check that read context.isSystem there would have been a declared elevation bypass that no caller of this transport can take, which is exactly what check-system-context-census reddened on c9771407 (a new isSystem read site with no page anchor) and what the census page would then have had to register as a bypass. Removing the read, and the test case that drove the bridge with a system context it can never hold, is the correct repair rather than the registration; the exposure gate's own isSystem skip is a pre-existing mirror of callData and is not widened. The census is exit 0 on this head. Judged consistent with ADR-0101.
  3. The stdio pins (stdio-data-bridge.exposure.test.ts, new block on the file's existing doubles). update refused on the shipped declaration with find and update never called; create, update and remove refused on an unrestricted declaration with insert, find, update and delete never called; the shipped declaration's 405 ahead of the rule for create and remove; reads of sys_api_key served (both reach the engine); a write to another object served with the engine's arguments asserted. Each refusal pins the envelope (code, status, object). The dev's ablation of the helper (refusal made never to throw: the two refusal cases red, 405, reads and the other-object case green; blob restored to HEAD) is consistent with these pins; not re-run. Correct.
  4. The merge of origin/main (should-fix 1). a9befcfe merges main; 79bf748ee (harden(keys): minting an API key requires an interactive session #27536) is now an ancestor, and domains/keys.ts on this head carries the mint's admission.credential !== 'session' refusal, so the premise the docblock, the changeset and the body state is on the tree reviewed. The merge brings no change to this PR's files beyond the merge itself; mergeable_state is unstable only for the one in-progress check (③-d). Correct.
  5. Core docblock. The rule module now names the three gates that ask it (the dispatcher's, RestServer.enforceApiAccess with its cross-object batch, and the stdio transport's write arms). No code line of the module moved; the four public names are as before (① item 13 of the earlier record). Correct.
  6. @objectstack/mcp joins the changeset at patch. Right: its published bridge now refuses a write it served.

② Semver level

Declared on the changeset and the body, both line-initial: Clause-②: yes (narrowing), Metadata-protocol: none; @objectstack/core, @objectstack/rest, @objectstack/runtime, @objectstack/mcp at patch; Skills-docs: none — REASON (unchanged, measured). The yes now matches the four names added to core's entry, and the ADR-0087 marker says the same ("four names are added … the widening this changeset's yes declares"); the (narrowing) arm, the **BREAKING** (an accept-set narrowing) banner and the single marker in not-required (no-migration-prescription) are as before. The TO now names the three doors by their routes and tools and no longer says "every data door" without the list; "What does not change" keeps reads, the session, system-context writes and the 405. This is must-fix 1 as asked, in the precedent spelling (packages/core/CHANGELOG.md). The dev's local readings (adr-0087-registration exit 0 with [BREAKING+clause-②-narrowing]; the no-major gate's level axis reading yes (narrowing) / none at patch from this body) are consistent with the first Check Changeset run's success on this head; the body-edit re-read is ③-d.

③ Boundary flags

  • (a) Reads stay open on every door (option A, now a seat decision stated in the body): no reason against, as in the earlier record; the stdio read arms are untouched and pinned served.
  • (b) "The stdio check reads no elevation flag: the transport's principal is never the system context." Judged in ① item 2: right, and the reason is ADR-0101 plus assembleExecutionContext's isSystem: false, both read here.
  • (c) Body describes the head. The signature line first, then the three declaration lines; 维护者速读 names all three doors, the four new core exports as the reason for yes, and the two-minute check at 32/32; "What changes" lists the stdio write arms as the third door and the ExecutionContext as unchanged; Tests names the new stdio block; Acceptance notes carry the seat's option-A decision, the no-elevation-flag note and the merge; Verification is on 853277e1 for every suite and gate, with the rule ablation marked as taken on abe793d0 over an unchanged module. No drift.
  • (d) Checks on 853277e1 at review time: every concluded non-skipped run is success (39 distinct; the required contexts among them: Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core, Governed Surface Queue Guard, Lint UI, Type Check UI, Test UI, Build & E2E UI, Live E2E UI); the second Check Changeset run, the re-read triggered by the body edit, was in_progress. The landing pre-check (全绿) is the seat's read at landing, not this record's.
  • (e) Battery for the (narrowing) line: the dev's full dogfood run on 853277e1 under the lock (353 files passed / 3 skipped, 2853 tests passed / 20 skipped, exit 0) and CI's Dogfood Regression Gate success; read, not re-run.
  • (f) open_questions: none. Author is objectstack-fleet[bot]; no login note owed.

④ Design judgment

Unchanged from 6506196540 on the rule and its two original doors. On the delta: the third door takes the rule the way the first two do — beside its own exposure gate, after the exposure verdict, before the engine, from one definition — and hands it the one fact that door knows better than any resolver, that its credential is an API key; declaring that fact as a constant rather than threading an admission the stdio transport does not have is the honest shape, and refusing to read an elevation flag the principal cannot hold keeps ADR-0101's "no system bypass" literal in the one place a bypass could have crept in. The changeset now states the contract the tree delivers. No must-fix; no should-fix outstanding.

Implemented-by: claude/harden-api-key-data-door
Reviewed-by: session_017xRBcVy4VXmkQjn2MGeaWk

VERDICT: PASS

Delta review complete: the declaration is yes (narrowing) in the precedent spelling, the stdio MCP door asks the rule on its three write arms with pins and an ablation consistent with them, the isSystem read's removal is correct under ADR-0101, origin/main is merged so the mint-rule premise is on this tree, and the body describes the head. The seat reads the second Check Changeset run's conclusion before landing.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk

ACCEPT → head 853277e1dfa7f78ad3c249d32bede7e0a9f30240 · 2026-10-29T20:26Z

维护者速读

  • 改了什么:用 API Key 认证的请求,不能再改写 sys_api_key,会被拒为 403 PERMISSION_DENIED,什么都不写入。所有数据入口都按这一条执行,包括 REST 单对象路由、跨对象 /batch、运行时 dispatcher(/data、callData、/mcp、动作入口)和 stdio MCP。读取不受影响。规则和 /api/v1/keys“管理 Key 需要登录会话”是同一条。
  • 为什么改:QA 第三轮发现的安全加固(细节按披露裁决不公开)。
  • 风险与回滚:这是 BREAKING 收窄,按 patch 发布。只会影响用 API Key 调数据接口去改 sys_api_key 的脚本。登录会话和系统上下文写入不变,未暴露的操作仍然先回 405。回滚即 revert 本 PR,不涉及数据迁移。
  • 先看:packages/core/src/security/api-key-management-credential.ts、packages/mcp/src/stdio-data-bridge.ts、packages/rest/src/rest-server.ts(enforceApiAccess)。
  • 两分钟验证:pnpm --filter @objectstack/dogfood exec vitest run test/api-key-data-door-session-required.dogfood.test.ts,预期全部通过。
  • 需要你做的:无。读取保持开放(方案 A),这是席位的决定,复核也同意。

席位意见:收。本 head 上增量契约复核 PASS(6507539519,判的是 6506196540 之后的增量);dogfood 全套 353 个文件、0 失败。

席位核对

  1. 形态:draft、目标 main;按披露裁决不建卡,正文无关闭关键词。正文由席位按 dev 暂存稿写入,回读一致。
  2. 前提实测:复核记录 6506196540 和 6507539519 都读过代码。harden(keys): minting an API key requires an interactive session #27536 已合入本分支。stdio 新检查做过 ablation,2 个用例变红,恢复后转绿。
  3. 门禁:本 head 共 41 个 success、12 个 skipped、0 失败;Lint & Repo Gates、TypeScript Type Check 都是 success,Check Changeset 两次运行都是 success。
  4. 接受的偏差:stdio 那段代码去掉了 isSystem 提前返回。原因是 stdio 主体按 ADR-0101 永远不是系统上下文,复核判定这样改正确。
  5. 范围外去向:非安全的后续已正常立卡(lint(searchable-fields): report a searchableFields entry that names a secret or password field, which the engine now drops without a word (follow-up to #27477) #27705–qa(authz-conformance), docs(api): record that POST /api/v1/keys mints only for a signed-in session, as a matrix row and a one-line docs mention (follow-up to #27536) #27708);本 PR 不产生新的后续。Skills-docs: none — REASON 有实测的 grep 结果支撑。
  6. 落地:不属于受管面,879 行,低于 3000 行上限 → pr_ready + automerge_enable。

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 29, 2026 20:27
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 29, 2026 20:27
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 29, 2026
Merged via the queue into main with commit 04b1402 Oct 29, 2026
54 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/harden-api-key-data-door branch October 29, 2026 20:50
os-support-ai pushed a commit that referenced this pull request Oct 30, 2026
…aks QueryAST

After the main merge the API-exposure gate takes the request's context
(#27692), so the record page route now hands it the context it already
holds. The related list read now builds a canonical QueryAST (where,
limit, orderBy, and count as the find request declares it) with no cast,
instead of the wire aliases behind `as any`, which the canonical-query
census in the rest repo suite refuses; the census gets the new module's
floor of one slot (sum 4 to 5). The route test narrows the related
answer union where it reads one arm.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzhCjfxaYJQyy6tFjVr
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant