Repository navigation
feat(runtime, rest, plugin-auth): an OAuth agent's api-action forward carries a server-minted forward credential (ADR-0090 D10 rule 5) - #28482
Conversation
… carries a server-minted forward credential (ADR-0090 D10 rule 5) The two cells the #28367 amendment flips, and nothing else: an agent connected over OAuth, forwarded to an identity route or to another same-origin route, now reaches the target as the same agent principal instead of being refused by name. The platform cell is bounded by the scope-family rule. api_key x identity keeps refusing by name. - @objectstack/rest forward-credential: mint, bind (method, route, run), spend on first presentation, revoke when the forward answers, expire, and redeem only inside the forward's own async chain. - runtime action door: the table's two flipped cells, the scope-family predicate shared by run_action, the REST action door and list_actions, no caller identity header on an agent's forward, no rotated credential relayed back. - runtime dispatcher and REST face: a redeemed credential is the agent's provenance for the shared assembler; no admission is reported for it. - plugin-auth identity mount: a request-scoped, in-memory view of the delegating user for better-auth, no session row, the agent's client carried as performedBy on identity writes. - ADR-0090: the two sentences the amendment names are corrected. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibyagSyLB5ckJsKghoa
…gine-double contract); plugin-auth reads @objectstack/rest as its other tests do Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibyagSyLB5ckJsKghoa
…registered after bearer() on every instance Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibyagSyLB5ckJsKghoa
📓 Docs Drift CheckThis PR changes 3 package(s): 61 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 14 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 50 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 ad374db74fb378f3e556577e27513ade6f5d4d5a && git checkout ad374db74fb378f3e556577e27513ade6f5d4d5a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ee29ac39e840de60bc0c9812d6113aac321c51a0 684ebbfa9c65c491842c91a053a22625883803cc && git checkout -B drift-repro ee29ac39e840de60bc0c9812d6113aac321c51a0 && git merge --no-ff 684ebbfa9c65c491842c91a053a22625883803cc
node scripts/docs-audit/affected-docs.mjs --json ee29ac39e840de60bc0c9812d6113aac321c51a0
|
…the wildcard fall-through guard can verify it yields Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibyagSyLB5ckJsKghoa
|
triage#1 · os-tesla · session_01L9qibw58ehQ2gRHP2h2Puz Contract-review-claim: |
|
triage#1 · os-tesla · session_01L9qibw58ehQ2gRHP2h2Puz Contract reviewServed-tier: ① Ruling → code
② Declarations
③ Pins and ablations
④ Traps and should-fix
Implemented-by: VERDICT: PASS |
|
domain:server#1 · os-tesla · session_01L9qibz3ZUbZ8Qcn1rDw9yc 维护者速读
席位意见:契约复审 PASS,翻的正好是修正案点名的两格,其余拒绝都有按名的测试钉住。可以批准。 席位核对ACCEPT · seat
|
Requested by Tesla · project thread
Fixes #28367
Clause-②: yes (widening, at the two named cells only)
Metadata-protocol: none
This is step 2 of the ruling's order, the code half of the ADR-0090 D10 rule 5 amendment that landed in #28455 (a99de44). Ruling of record 6545950368 (go, as shaped). Ruling of record 6548923676 (batch #436 item 1, A):
api_key×identitykeeps refusing.维护者速读
api动作时,进程内转发不再被点名拒绝,而是带一张服务端为这次转发单独签发的转发凭据。凭据绑定这一次的方法、路由和动作运行,第一次出示即作废,目标应答即撤销,30 秒上限,只在进程内本次调用链里能兑换。目标端把它解析成同一个 agent(scope 上限、代表的用户、客户端),身份路由处 better-auth 只看到委托用户的请求级视图,不建任何会话。actions:execute+data:write,元数据门的写操作仍按名拒绝(403)。API key × 身份路由按裁决 6548923676 继续拒绝。目标端签出的会话 cookie 或轮换 token 不会回到 agent。回滚即 revert 本 PR,无数据迁移。packages/rest/src/forward-credential.ts(凭据四条性质),packages/runtime/src/action-execution.ts的forwardApiAction和 scope 族规则,packages/plugins/plugin-auth/src/agent-forward-identity-view.ts(身份路由的请求级视图)。mcp-oauth-api-action-forward.dogfood.test.ts里真实 OAuth agent 改自己资料成功、审计行同时记performed_by和on_behalf_of、只读 agent 和元数据写被按名拒绝。docs/adr/**的两句话,席位会发起评审请求)。下方 Acceptance notes 里有两个开放问题请裁决。What changes
packages/rest/src/forward-credential.ts(new). The one producer and redeemer of the credential.withForwardCredentialmints 256 random bits per forward, bound to the method, the route and the action run, and revokes it infinallywhen the forward answers.redeemForwardCredentialanswers the agent's provenance only inside the forward's own async chain, spends it on the first presentation (accepted or refused), and refuses another method, another route or an expired one.packages/runtime/src/action-execution.ts.oauth×identityandoauth×platformnow forward as the agent.api_key×identityis unchanged and still refuses by name.apiForwardScopeFamilyRefusal):actions:executealways,data:writefor a state-changing method, and a state-changing metadata-door target refused. Each refuses403PERMISSION_DENIEDby name, before the mint.list_actionsmarks the action from the same predicate.packages/runtime/src/security/resolve-execution-context.tsandpackages/rest/src/rest-server.ts. A redeemed credential is handed to the shared assembler as theoauthprovenance, exactly as the MCP door hands a verified token's. The target builds the same agent principal (scope-derived permissions,onBehalfOf,performedBy,oauthScopes, the consent organization). Credential admission is not recorded for a forwarded request. An unredeemable credential is ignored.packages/plugins/plugin-auth.agent-forward-identity-view.ts(new) is a middleware on the auth base path plus a better-auth plugin. A redeemed credential becomes a request-scoped in-memory view of the delegating user: nosys_sessionrow, no token, no cookie. A banned or missing user gets no view. Credential response headers are stripped for the agent caller.auth-actor-attribution.tsandobjectql-adapter.ts: identity writes made inside the view carryperformedBy, so the audit row recordsperformed_byandon_behalf_of.auth-plugin.tsandauth-manager-plugin-list.ts: wiring.docs/adr/0090-…md. The two sentences the amendment names are corrected: rule 4's "today the OAuth / MCP client door alone", and the 2026-09-16 note's "REST and stdio hand the assembleroauth: undefinedby construction".content/docs/ai/actions-as-tools.mdx. Theapirow states the new table.forward-credential.test.ts(9),rest-forward-credential-agent.test.ts(5),resolve-execution-context.forward-credential.test.ts(5),agent-forward-identity-view.test.ts(5), and a new [Decision] auth: an agent forward credential — a server-constructed, target-bound, single-use credential so an OAuth- or API-key-connected agent can run an api action whose target is an identity route (ADR-0090 D10 amendment) #28367 describe inapi-action-forward.test.ts(58 total in the file).mcp-oauth-api-action-forward.dogfood.test.tswas rewritten (10), andmcp-system-object-action-exposure.dogfood.test.tsnow serves the formerly refused identity pins, with a member-agent intersection pin added (12)..changeset/28367-agent-forward-credential.md(patch for rest, runtime and plugin-auth).Acceptance notes
apiKeyManagementCredentialRefusal, which refuses only theapi_keycredential. The agent's own scope ceiling and the user's permissions still apply. Recommendation: refuse agent principals there too, as a follow-up on the key-management door, since that door is outside this PR's surface.handleRequest. The amendment namesauth-manager-request-routing.ts#handleRequest. The platform's raw identity mounts (for exampleupdate-user) are served without passing throughhandleRequest, so a seam there would miss them. The middleware runs ahead of every route under the auth base path, after the IP gate.createdAtis the request time, so a route that requires a fresh session accepts the agent's view. The user's own consent and better-auth's own checks still stand. Flagged for the contract review.data:write. Thedata:readbranch is implemented as the rule states and unit-pinned.run_action. The REST action door shares the table function and is pinned at unit level.scripts/engine-double-contract.pinned.json(regenerated with--writefor the new test doubles), fivescripts/adr-anchors/*.jsonentries for the new and changed files,packages/rest/src/index.ts(exports), andpackages/runtime/src/domains/actions.ts(passesrecordIdinto the forward binding). ⛔ Nopackages/spec, nometadata-protocol/src/protocol.ts.main52306d4 before the code: both flipped cells refused by name,api_key×identityrefused,api_key×platformserved, and an outside OAuth token refused 401 at the data door and the identity mount (2 dogfood files, 16 of 16 pass). Kept at rule level, since this is a security card.Verification
Local, on
6bbf2eeb:node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 110 families for the changed paths. Each ran with stdin from/dev/null, and its exit code was captured by redirect before any pipe.Gate readings
check-plugin-teardown-shape --self-test: the pinned commit was missing from the shallow clone; aftergit fetch origin 621a4876… --depth=1it exits 0.check:engine-double-contract: the new test doubles; ledger regenerated with--writeand committed.check:objectql-double-limit: both new doubles now honourlimit.check:where-matcher: the new matcher refuses$combinators and operators.check:test-source-alias: a plugin-auth vitest alias was reverted; plugin-auth reads@objectstack/restas its other tests do.dispatch-gates --ran:110 derived famil(ies) accounted for — 110 run, 0 NOT-MEASURED, exit 0.pnpm --filter <pkg> run typecheckfor@objectstack/rest,@objectstack/runtime,@objectstack/plugin-authand@objectstack/dogfood, each exit 0.pnpm lint, scoped topackages/rest,packages/runtime,packages/plugins/plugin-authandpackages/qa/dogfood/test: exit 0.@objectstack/rest: 6884 passed.@objectstack/mcp: 603 passed.@objectstack/runtime: 8055 passed.@objectstack/plugin-auth: first run 2 failed (the two plugin-list pins inauth-manager.test.ts, which now name the new view plugin); after the fix, 3938 passed.Ablations (each pin red with the forbidden behaviour put back, then restored byte-identical; `git status` clean after each)
Unit, via
scripts/ablation-replace.mjs:api_key×identityaccepteddata:writerequirement removedactions:executerequirement removedDogfood, via
scripts/ablation-dist-preflight.mjson the built dist:data:writerequirement removedapi_key×identityacceptedGenerated by Claude Code