Repository navigation
fix(core,cloud-connection,mcp)!: every API-key admission names its owner-posture source, so a held owner's key is refused at install-local and on every MCP stdio call (#23455, #23456) - #23508
Conversation
…seam names its owner-posture source (#23455, #23456) [WIP] Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
…local and per stdio call (#23455, #23456) [WIP] Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
…se a held owner's key (#23455, #23456) Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
…y-admission-posture-family
📓 Docs Drift CheckThis PR changes 5 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 42 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 e70398ac366d29035d5fb806996075faa603805b && git checkout e70398ac366d29035d5fb806996075faa603805b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f9b421716dd79df6c7c3a34bbd5a3a9f425e84f4 fc4aff4a17a782a70e82cecb27e8effe36e2a144 && git checkout -B drift-repro f9b421716dd79df6c7c3a34bbd5a3a9f425e84f4 && git merge --no-ff fc4aff4a17a782a70e82cecb27e8effe36e2a144
node scripts/docs-audit/affected-docs.mjs --json f9b421716dd79df6c7c3a34bbd5a3a9f425e84f4
|
Contract reviewServed-tier: ① Derived judgmentsInputs: cards #23455 and #23456 (bodies, the family scope
Public surface otherwise: no route, header, status or body moves beyond the two narrowings; no governed path in the file list ( ② Semver level
③ Boundary flagsThe dev's deviations, each answered:
Q1, retire Q2, the stdio code on the wire: this PR is correct without it. The seam refuses with the posture's Out-of-scope findings: (a) the flattening above, filed; (b) ADR-0101 (Tier H) and Check-runs on this head, read at 2026-10-14T15:36Z: 40 runs, 33 Implemented-by: VERDICT: PASS |
席位核对
Verdict: ACCEPT. Not governed (
|
Fixes #23455
Fixes #23456
Clause-②: no
What changed
This PR covers the family card #23455 (one card for the family) and #23456. It closes the seams of ruling C′ on #23287 (
6159450584, maintainer 「同意」) that PR #23440 did not reach. It then makes the owner-posture input required, so the compiler names any seam that forgets it.The ruling, quoted:
AUTH_GATE_SCOPESdecides which posture holds a key.MFA_REQUIREDhas scopeaccountand holds the key.PASSWORD_EXPIREDandPASSWORD_RESET_REQUIREDhave scopecredentialand do not.1. The marketplace install-local resolver (
@objectstack/cloud-connection, member 1)resolveInstallPrincipaldoes two new things:userAuthGates: userAuthGateSourceOf(authService). It reaches the auth service throughctx.getService('auth'), read per request, the way its sibling seams do.evaluateAuthGateabout the resolver's own posture (authz.authGate), instead of the session user it used to capture.So all five doors refuse a held owner's key through the existing
refuseHeldSession:403, the posture'scodeand the session'smessage, before any effect. A held session is refused exactly as before.2. The MCP stdio face, asked on every call (
@objectstack/mcp, member 2, #23456)Measured on
origin/main:resolvePrincipalcallsresolveStdioExecutionContextfrom every data-bridge verb and from the record resource (ADR-0101 D1, the same schedule as revocation and mcp: the stdio door resolves an API key with notenancyPosture— an ex-member's org-stamped key is admitted with its own unvetted claim #15348's tenancy posture).What changed:
resolveStdioExecutionContexttakes a REQUIREDuserAuthGatesparameter.resolveStdioUserAuthGates(ctx)reads it per call, besideresolveStdioTenancyPosture. The function now assemblesauthGate: authz.authGateinstead ofauthGate: undefined.resolvePrincipalasksevaluateAuthGateon the context and refuses a held owner's call before any read or write. The refusal is a thrown envelope:codeMFA_REQUIRED,status403, and the session's remediationmessage. That is what REST'senforceAuthand the dispatcher answer, in the shape this transport's other refusals take (recordNotFoundError).warnline names the hold and the remedy. The key is valid and nothing revokes it. A refusal to start would outlast the remediation until someone restarted the process, which item 4 rules out.3.
resolveApiKeyPrincipal(@objectstack/core, member 3): wired, not retired, on an unmeasured premiseThe ruling's premise for retirement is that no non-test caller exists here or in
objectstack-ai/objectuiorobjectstack-ai/cloud.fc4aff4a17git grep resolveApiKeyPrincipalfinds the definition, its tests and three re-exports (core'ssecurity/index.ts, runtime'ssecurity/api-key.tsandsecurity/index.ts), plus prose. No non-test call.mainat52d61943c0git grepped there: zero hits forresolveApiKeyPrincipal. Control: the same grep forresolveAuthzContextreturns 8 lines (comments and docs), so the grep reads that tree.add_repoanswered "you don't have access", and a REST read answered 403. No published cloud package exists on npm to read instead./search/*was not used.Since a live cloud caller could not be ruled out, the ruling's prohibition branch was taken: no retirement, and no silent fail-open.
resolveApiKeyPrincipaltakes a REQUIREDuserAuthGates.heldverdict collapses to no principal (fail closed).resolveApiKeyAdmissionand refuseheldwithgate.code.Open question 1 asks the seat to retire it once cloud is read.
4. The class fix: the owner-posture input is REQUIRED (
@objectstack/core)This is PR #23440's seat record item 6:
ResolveAuthzInput.userAuthGateschanges from?: UserAuthGateSourceto: UserAuthGateSource | undefined.resolveApiKeyAdmissionandresolveApiKeyPrincipal,userAuthGatesis now required.tenancyPostureis required with it, because a required parameter cannot follow an optional one.undefinedis a value a caller passes on purpose. The docblocks say where it is legitimate: a kernel with noauthservice. They also say where it is not: a seam that could reach one.Premise measured: it holds. Every in-tree production caller names its source without new plumbing.
computeExecCtx, runtimeresolveExecutionContext, sharing resolver, datasource admin, settings resolver, storage door gate readeruserAuthGateSourceOf(…)already (PR #23440)resolveInstallPrincipalctx.getService('auth')resolveStdioExecutionContext(start probe and per call)ctx.getService('auth')buildFileReadAuthorizerregistry.getService('auth')through a sharedresolveGateAuthServiceresolveAuthzContextandresolveApiKeyPrincipalThe compiler's enumeration matches the
git grepcensus: after the change, every package holding a call site type-checks (see Verification).Storage, measured. The authorizer's call is an explicit pass-through, not a second gate:
mountFamilyRoute. Its door asksbuildAuthGateSessionReader, which already refuses a held owner's key on the same request.composeStorageRoutesbinds the reader and the authorizer from the sameauthservice.Test and kit callers.
userAuthGates: undefined, and 20 positional test calls gained explicitundefinedarguments. They present no key, or test something other than the owner's posture, so their subjects are unchanged.resolveAuthzFor(armed.ts) documents itself as resolving "the SAME way the REST entry point does", so it gets the real source from the stack'sauthservice.Pins (three facts per seam, PR #23440's shape)
MFA_REQUIRED)cloud-connection/src/marketplace-install-local-api-key-account-posture.pin.test.ts403,{ code: 'MFA_REQUIRED', message }on every door, zero effects, ledger untouched403, then served; key row unchangedmcp/src/stdio-api-key-account-posture.pin.test.ts(real kernel, real core chain){ code: 'MFA_REQUIRED', status: 403, message }; nothing written; a realStdioServerTransporttools/callanswersisErrorwith the remediation messagecore/src/security/api-key-account-posture.test.tsresolveApiKeyPrincipalanswers no principalThe compile-level pin is in the same core file. Four
@ts-expect-errorlines cover each omitted input: theResolveAuthzInputkey, the admission with two and with three positional arguments, and the principal with four. Explicitundefinedcontrols sit beside them. They live in a function that is never invoked and are read bycheck:test-typecheck's tsc program, where an unused directive is TS2578.Negative control (reverse verification), on the committed head
a15aecc0aaEach leg went through
scripts/ablation-replace.mjs. The anchor hit exactly once, and the blob changed. A restore trap covered all four paths. After each leg the blob equals HEAD andgit diff HEADis empty. The cloud-connection and MCP subjects are imported fromsrc, and the compile pins readsrc, so no dist leg applies.userAuthGates: undefined2 failed / 1 passed: the held and clears cases are red, the credential-scope control is greenif (gate)guard ANDed with a never-true clock comparison)2 failed / 1 passed, same splitResolveAuthzInput.userAuthGates?optional againcheck:test-typecheckred: one unledgered type error inapi-key-account-posture.test.ts(the unused directive)resolveApiKeyAdmission'suserAuthGates?optional againcheck:test-typecheckred, same fileVerification
Every reading below is on head
fc4aff4a17. That is the branch afterorigin/main394f65e891was merged in, withpnpm install --frozen-lockfileand the closure rebuilt.testscript,--maxWorkers=2), all exit 0:security-catalog-echo-env-kernel.integration.test.ts, the one cli file in the diff) 1 / 9;resolveAuthzForhelper.pnpm --filter … run typecheck, including each package'scheck:test-typecheckledger) exits 0 for all 13 packages that hold a call site: core, mcp, cloud-connection, service-storage, rest, runtime, plugin-sharing, plugin-security, plugin-auth, service-datasource, service-settings, dogfood and cli.eslint --no-inline-configover the 37 changed.tsfiles reports 0 errors and 0 warnings.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 76 commands fromfc4aff4a17. All 76 ran, each recording its exit code before any pipe.--ran:76 derived, 76 run, 0 NOT-MEASURED, 0 UNRUN.check:dual-build-cjs-loadsfirst refused with exit 3 (PREREQUISITE NOT MET: eight packages outside this diff had nodist/). After those were built, it exited 0 (110 entry points across 68 packages load). The reconciled list carries the re-run.check:adr-0087-registrationverified the changeset'sruntime-interface-onlyoperand.Acceptance notes
errorResult(messageOf(err)), and the record resource writes{ error: message }. Both are shared by the HTTP and stdio faces, and both flatten EVERY coded refusal on this transport to its message,RECORD_NOT_FOUNDincluded. MCPrun_actionnever enforcesai.requiresConfirmation— it is surfaced inlist_actionsonly, while@objectstack/specguidance tells authors it stops execution #15942 adopted envelope rendering (errorResultFromThrown) for the action tools only. The stdio refusal itself carriescodeandstatus, and the client receives the session's remediation message verbatim. Reported as an out-of-scope finding, and see Open question 2. The HTTP MCP face is unaffected: the dispatcher refuses the whole request with403 MFA_REQUIREDbefore the MCP server sees it.list_objects,describe_object, prompts and resource listings resolve no principal (the ADR-0101 boundary: the identity binds row data). So a held key reaches them exactly as a revoked key does today. Carrier: none.resolveApiKeyPrincipalas part of the stdio verify chain (Tier H, not edited).qa/dogfood/test/authz-conformance.matrix.ts's stdio row does the same.The chain is
resolveAuthzContext→resolveApiKeyAdmission. This PR's own docblock inmcp/src/plugin.tsis corrected.check:adr-0087-registration: a detectable FROM → TO for a required-parameter change is refused under every honest disposition.no-migration-prescriptionandruntime-interface-onlyinherit the prescription refusal.type-surface-onlyneeds a base type that was erased.runtime-export-retiredneeds a retired export.So the remedy is one instruction line, and the marker is
runtime-interface-onlyonresolve-authz-context.ts#ResolveAuthzInput. The gate verified that marker.@objectstack/runtimeis gradedminorwith no source change. Its entry re-exportsresolveApiKeyPrincipalexplicitly, and the rest throughexport *, so its importers see the new required inputs..claude/**,docs/adr/**,skills/**,AGENTS.mdor workflows.Open questions
resolveApiKeyPrincipalonce cloud is read? The ruling's premise could not be measured on cloud from this session.runtime-export-retired @objectstack/core#resolveApiKeyPrincipal, and@objectstack/runtime's two explicit re-exports go with it.errorResultFromThrownon the data tools and the record resource. That makes the code machine-readable on both faces for every refusal: a widening of what a client reads, with tests that pin today's text to re-judge.Generated by Claude Code