Repository navigation
harden(data): changing an API key through the data door requires a signed-in session - #27692
Conversation
…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>
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>
…ssertion Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 4 package(s): 18 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 3 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 66 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
|
domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk Contract reviewServed-tier: Face: narrowing, declared. The diff touches no Security-hardening ruling: this record names positions and refusals; it carries no reproduction. ① Derived judgmentsThe accept set and the public surface the diff changes, read off the code on this head:
② Semver levelDeclared on the changeset and the body: The The rest of the declaration holds: the ③ Boundary flags
④ Design judgmentOn the narrowing face, against the published spec on 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,
Must-fix
Should-fix
Implemented-by: VERDICT: FAIL The refusal is correctly placed and complete on the two doors the PR names, the |
Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk Co-authored-by: Claude <noreply@anthropic.com>
… 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>
…e bridge uses 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>
|
domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk Contract reviewServed-tier: Face: narrowing, declared. Delta review of record 6506196540 (FAIL on the declaration and the stdio MCP door, head Security-hardening ruling: this record names positions and refusals; it carries no reproduction. ① Derived judgments
② Semver levelDeclared on the changeset and the body, both line-initial: ③ Boundary flags
④ Design judgmentUnchanged 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 Implemented-by: VERDICT: PASS Delta review complete: the declaration is |
|
domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk ACCEPT → head 维护者速读
席位意见:收。本 head 上增量契约复核 PASS(6507539519,判的是 6506196540 之后的增量);dogfood 全套 353 个文件、0 失败。 席位核对
Generated by Claude Code |
…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
domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk
Clause-②: yes (narrowing)
Metadata-protocol: none
Skills-docs: none — no file under
skills/**namessys_api_keyor 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.维护者速读
/api/v1/data/sys_api_key及批量与跨对象/batch、运行时调度器的/data与/mcpHTTP 工具与动作入口、stdio MCP 的写工具)上,凡由 API Key 认证的调用方对sys_api_key的写入,一律拒绝403 PERMISSION_DENIED,什么都不写;读取不变。与/api/v1/keys的「签发 Key 需要登录会话」是同一条规则(该规则已随 main 合入本分支)。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 用例:写入 403PERMISSION_DENIED且引擎未被调用,未暴露操作仍 405,读取与其他对象照常)。What changes
Managing API keys is a signed-in-session act on every data door, as minting one through
POST /api/v1/keysis. A write tosys_api_keyfrom a caller an API key authenticated is now refused403 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.apiKeyManagementCredentialRefusal, added to@objectstack/core's entry withAPI_KEY_MANAGEMENT_READ_OPERATIONS,API_KEY_MANAGEMENT_SESSION_REQUIRED_MESSAGEand the typeApiKeyManagementCredentialInput(four new public names, henceClause-②: 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.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), andcomputeExecCtxkeeps the credential the shared resolver reports throughonAdmissionas the internal__admittedBykey, beside__authEnvironmentIdand__environmentId. The cross-objectPOST /batchasks the rule per op, after its own per-op exposure check.refuseUnexposedDataAction— reached by/dataand bycallData, so the/mcpHTTP tools and the action doors too. The credential isHttpProtocolContext.admission, which the identity step writes.create,update,removeinstdio-data-bridge.ts, thecreate_record/update_record/delete_recordtools). That transport admits its principal fromOS_MCP_STDIO_API_KEYalone (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.last_used_atstamp, the forced reset); theExecutionContextshape (no spec type widened); a dispatcher context with noadmission(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;405ahead 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:/dataPATCH andcallDatarefused (rendered through the dispatcher's ownerrorFromThrown), 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:updaterefused on the shipped declaration andcreate/update/removerefused on an unrestricted one, each with the engine never asked; the shipped declaration's405ahead of the rule; reads ofsys_api_keyserved; 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-keyonly: each refused write asserts status,codeand 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 thelast_used_atstamp still land.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
sys_api_keyby 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.RestServerserves/api/v1/data, so the dogfood file exercises that door. The stdio transport is pinned at the bridge, where its write arms live.namecase: the column whitelist already answered it403 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).origin/mainis merged in (a9befcfe), so thePOST /api/v1/keyssession rule this one mirrors is on this tree.Verification
Final head
853277e1(objectstack-ai/objectstack;origin/mainmerged ata9befcfe).Package suites and type checks (all at 853277e)
@objectstack/coretest: 127/127 files, 2868/2868 tests. typecheck exit 0 (check:test-typecheck: OK, 4 pinned signatures held).@objectstack/resttest: 341/341 files, 6673 passed, 326 skipped. typecheck exit 0 (0 pinned).@objectstack/runtimetest: 478/478 files, 7790 passed, 19 skipped. typecheck exit 0 (68 pinned signatures held).@objectstack/mcptest: 45/45 files, 586/586 tests. typecheck exit 0 (8 pinned signatures held).--no-inline-configover every touched source and test file: exit 0.Ablations (one-shot, restored to the HEAD blob)
The rule (at
abe793d0;@objectstack/coresource unchanged since apart from its docblock): the credential test inapiKeyManagementCredentialRefusalmade always-true for "not applicable", core rebuilt,ablation-dist-preflightmarker present in 2 built files. Core 6 failed / 14 passed, runtime 2 / 4, dogfood 3 / 6 (the threerevokedrefusals red;namegreen because the column whitelist refuses it first; controls green). Restored to the HEAD blob,--absentmarker absent from all 14 built files, tree clean, the three files green again.The stdio check (at
853277e1): throughscripts/ablation-replace.mjs(WRAP mode, anchor hit x1, blob6aaa0d9e5074to61c98e653613) 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 == HEAD6aaa0d9e5074,git diff HEADempty, whole-treegit status --porcelainempty.Dogfood battery (declared narrowing)
pnpm --filter @objectstack/dogfood testunder the verify lock at853277e1: 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 --commandsat853277e1: 68 commands, each run with its exit captured before any pipe, all exit 0; pluscheck:changeset-fixed,check:error-code-casing,check:filter-alias-parity,check:route-ledger-censusandcheck: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 lackeddist/) andcheck:type-check-debt(its lock was held by the battery).--ranreconciliation: 68 derived, 68 run, 0 NOT MEASURED, 0 UNRUN.check:system-context-census, red onc9771407(a new elevation read in the stdio helper), is exit 0 on853277e1: the read is gone.check:doc-anchorswas 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--eventover 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