Repository navigation
fix(runtime): an OAuth MCP agent acts in the organization its consent bound, so the user's organization-scoped grants count - #24229
Conversation
… bound
getSessionForProvenance built a verified token's synthetic session as
{ user: { id } }, so resolveAuthzContext derived no tenant and the ADR-0090
D10 delegator held its global grants only: every ordinary user's agent was
refused every object operation. The session now carries the verified
result's organizationId as its activeOrganizationId, so the session arm
vets it on every request (vetOrganizationClaim) exactly as it vets a
browser session's organization. The local hand-copied result type is
replaced by one derived from IAuthService, so the contract's member
cannot be dropped here again.
Pins drive the real resolver into the real SecurityPlugin middleware:
the positive case, another organization staying invisible, no grant
still refusing, a revoked membership falling back to no organization,
a platform admin's global grant unchanged, a claim-less token, and a
non-member organization dropped; plus the per-posture parity with a
browser session.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb
agents.mdx states the one organization a connected agent acts in, that membership is re-checked per request under an organization wall, and that a former member's agent gets an empty 200 read under isolated tenancy. Plus the runtime changeset. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb
…zation pins Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb
…One through the predicate guard The double opens findOne with assertEngineFindOnePredicate, and the engine-double ledger records it (check-engine-double-contract --write). Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb
📓 Docs Drift CheckThis PR changes 1 package(s): 2 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 — 26 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 74c9af0182c33a5fb9457f54397fbcfd70f52531 && git checkout 74c9af0182c33a5fb9457f54397fbcfd70f52531
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9040945d09154481565b9992b07e83273a0fb61e ee089fe5e49e7b0ba87c5c197d0ef3f418383acc && git checkout -B drift-repro 9040945d09154481565b9992b07e83273a0fb61e && git merge --no-ff ee089fe5e49e7b0ba87c5c197d0ef3f418383acc
node scripts/docs-audit/affected-docs.mjs --json 9040945d09154481565b9992b07e83273a0fb61e
|
Security reviewServed-tier: ① BoundaryBefore. After. The synthetic session carries
Measured against the ruling's text. One organization, the consent-bound one: yes, the claim is the consent's Places the token could act elsewhere or keep acting, each judged:
② Attack paths(a) An (b) A revoked or never-held membership. (c) The platform-admin global grant. (d) A token with no claim. (e) The synthetic session trusted without vetting. The session object is a closure local to (f) Docs and changeset. ③ PinsThe rig runs the REAL The ablation reading. 13 of 15 red is sound. Every control except the two claim-less ones opens with the organization it was vetted in (the bound ④ Flags
Check-runs on the head, read at 2026-10-17T23:41Z: 38. Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对 — ACCEPT · PR #24229 at
|
Fixes #24137
Clause-②: no
Ruling-ref: 6232069920
The runtime slice of ruling
6232069920(letter A; sub-question 1 =organization_id, 2 = (a)), as the split record6232498969assigns it. The two upstream slices landed first: #24165 (PR #24189,organizationIdonIAuthService.verifyMcpAccessToken's result) and #24166 (PR #24218, the consent binding and theorganization_idclaim). Until this PR nothing at runtime readorganizationId.What changed
packages/runtime/src/security/resolve-execution-context.tsgetSessionForProvenancebuilds a verified OAuth token's synthetic session as{ user: { id }, session: { userId, activeOrganizationId: organizationId } }. Before, it was{ user: { id } }.resolveAuthzContext's session arm readssession.activeOrganizationIdand vets it withvetOrganizationClaimon every request. That is the rule a browser session's organization gets, and nothing inpackages/corechanges. A token with no claim passesundefinedthere and resolves exactly as before.oauthPrincipaltype was a hand copy of the verifier's result withoutorganizationId. It is now derived fromIAuthService['verifyMcpAccessToken'], so the contract's member travels by construction. Of the three hand-copied shapes the spec review named, this one is gone rather than patched.packages/runtime/src/security/resolve-execution-context.oauth-organization.test.ts(new): the ruling's pins, listed below.scripts/engine-double-contract.pinned.json: one added row for the new file's engine double (itsfindOneopens withassertEngineFindOnePredicate), written bycheck-engine-double-contract.mjs --write.content/docs/ai/agents.mdx: the docs line, including that a former member's agent gets an empty200read (cross-lane:domain:devx)..changeset/24137-oauth-token-organization.md:@objectstack/runtimepatch,Clause-②: no.Landing point. Core's
OAuthTokenProvenance(assemble-execution-context.ts) is not changed. The organization reaches the envelope asauthz.tenantId, the session arm's output, whichassembleExecutionContext*copies ontoExecutionContext.tenantId. plugin-security's D10 delegator readscontext.tenantId(resolveDelegatorContext). Nothing reads an organization off the provenance object, so it does not need to carry one. Nopackages/core,packages/specorpackages/plugins/plugin-authfile is touched.Security boundary
What an OAuth token can now reach. Its delegator is resolved in the ONE organization the consent bound. The user's grants scoped to that organization now count for the agent, still intersected with the scope ceiling (ADR-0090 D10). Under
isolated, both principals' Layer 0 conjuncts name that organization.What it still cannot reach:
org_alpha, anorg_betagrant is refused at D10; bound toorg_beta, the reverse. It never gets the union.sys_oauth_resource.custom_claimsrow wrote onto the token: the runtime cannot tell such an override from a consent-bound value, and it vets both (H4).Posture table (H3). What the token's organization does in each posture, and whether a REST session naming the same organization does the same:
isolated200); the tenant write is refused (ADR-0123 D2)SecurityPluginmiddleware; token and session parity pinnedgroupgroupreadsaccessible_org_ids(every current membership) for every principal by the posture's design. That half is not measured heresingletenancyserviceSo "re-checked on every request" means membership-checked under
isolatedandgrouponly. Undersingle(the open-core default) a former member's token keeps acting in the bound organization, as the same user's browser session does there. The ruling asks for the session path, not for more. The docs line says so.Premise, measured (Section 2 hypotheses)
b9ac35cbf, the local type{ userId; scopes; clientId? }andgetSessionForProvenancereturning{ user: { id } }dropped the member. The pre-fix run of the new pins:Tests 11 failed | 1 passed (12). Each red was the first organization assertion, for exampleexpected undefined to be 'org_alpha'. In the PARITY case the same user's browser session resolvedorg_alphaand the token resolvedundefined.activeOrganizationId, the session arm kept a member's organization, and dropped a non-member's with core'sorganization claim dropped (organization_membership_ended)warn line (principal=u_ex organization=org_alpha). It then re-resolved with no organization. No new branch.organizationIdnaming an organization the user is not a member of is dropped underisolatedandgroup(pinned as a control). Undersingleor with no posture it is kept, as a session's claim is. An override naming an organization the user IS a member of is honoured, indistinguishably. See the acceptance notes.OAuthTokenProvenancedoes not need it (see Landing point). The implementer's type already carried it (PR feat(plugin-auth): an OAuth consent binds the consenting session's active organization, carried as organization_id #24218).isolatedandsingle).Pins
The REAL
resolveExecutionContextandresolveAuthzContextrun over a table double (@objectstack/coreresolves to source under this package's vitest alias). The resulting context goes into the REALSecurityPluginmiddleware (find/insert) over the same tables, so the layer that raised the card's denial decides each case. Only the token verifier is faked; its half (consent, claim,organizationId) is pinned against a real authorization server in@objectstack/plugin-auth.isolated:sales_repinorg_alpha(givingcrm_sales_user), token bound toorg_alpha.find crm_leadis admitted, walled toorg_alphafor both principals, andinsertis admitted.org_alpharesolves the sametenantIdandaccessible_org_ids.org_alpha, Mia'sorg_betagrant is refused at D10. Bound toorg_beta, the reverse.org_alphais refused at D10.RLS_DENY_FILTER(empty), and the insert is refused with the ADR-0123 D2 "has no active organization" denial.org_alpha) and unbound (walled to nothing on a business object, as before; ADR-0095 W2).posturestaysPLATFORM_ADMINboth ways.single: the card's case admitted (no Layer 0 conjunct); no claim refused, as before; a former member's token keeps the organization, exactly as their session does.All postures: a member organization is kept; a non-member one is dropped under
isolatedandgroupand kept undersingleand with no posture. Each is asserted for the token and for a session naming the same organization.Why this level is the decisive one, and not the MCP door. The denial the card quotes is raised by
SecurityPlugin's CRUD middleware, and the pins drive that middleware with the context the real resolver produced.@objectstack/runtimehas no dependency on@objectstack/mcp.http-dispatcher.mcp-oauth.test.tsfakes the MCP service and the data bridge with no security middleware, so aquery_recordsthere would assert nothing about D10.Ablation (once, fix committed first)
scripts/ablation-replace.mjsonresolve-execution-context.ts. The anchoractiveOrganizationId: oauthPrincipal!.organizationIdbecameactiveOrganizationId: undefined(anchor 1 to 0; blobbb3e258874d0toe2777512f5ce). The test imports the source relatively, so there is nodistleg.Tests 13 failed | 2 passed (15).tenantId, or the dropped-claim line. So they go red with the positive case.isolatedandsingle), the only pins independent of the organization.bb3e258874d0),git diff HEADempty (the tool's own verdict).Verification (final head
ee089fe5e)All runs went through
scripts/pm/os-verify-lock.sh(shared-box seconds).pnpm --filter @objectstack/runtime typecheck:check:test-typecheck: OK(27 files / 190 errors / 68 signatures held, unchanged),VERDICT command-exit 0.pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2:Test Files 410 passed (410),Tests 6295 passed | 19 skipped (6314),VERDICT command-exit 0.Tests 15 passed (15). Withresolve-execution-context.test.tsandhttp-dispatcher.mcp-oauth.test.ts:Test Files 3 passed (3),Tests 63 passed (63).pnpm exec eslint --no-inline-configover the two changed.tsfiles: exit 0, no output.turbo run build --filter='@objectstack/runtime^...'gave 30 of 30 tasks.turbo run build --filter='!@objectstack/docs' --concurrency=2gave 76 of 76 tasks, for the two gates that read every package'sdist.test:repo(CI'sTest Core). Theintegrationtier rule ispackages/cli's and does not apply.Gates (final head
ee089fe5e)node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(no paths; merge baseb9ac35cbf; 5 paths, +440 / -9) gave 100 commands.--ran:100 derived famil(ies) accounted for — 100 run, 0 NOT-MEASURED (a DERIVED zero — all 100 recorded an exit code and none of them is 3).check-engine-double-contract: OK — 1019 pinned, 120 in the DEBT ledger, 3 exempt;check:authz-resolver: single shared authorization resolver intact; both entry points delegate;check-nul-bytes: OK;doc authoring guard: … clean;check-adr-0087-registration: this PR adds no declared-breaking changeset;check:dual-build-cjs-loadsandcheck:skill-examplesexit 0 after the full build. On an earlier head (before the full build) both answeredPREREQUISITE NOT MET.node scripts/check-engine-double-contract.mjs --self-testexit 0.Lint & Repo Gates,TypeScript Type Check,Test Core,Build Core, dogfood) is CI's, read from this PR's check runs.Acceptance notes
mainmoved two commits after the base (9040945d0, PR fix(plugin-auth): the MCP access-token verifier follows the signing keyring instead of holding its own copy of the key set for five minutes #24225 for the plugin-auth verifier's keyring read, and PR refactor(rest): move the /meta type list, maintenance and per-type list routes into meta-collection-routes.ts (#23050 slice 13a) #24224 for a rest route move). Neither touches this diff's files,packages/coreor the verifier's result shape. Not merged; CI runs on the merge ref.sys_oauth_resource.custom_claimsnamingorganization_idoutranks the consent's value (the provider's merge order, per PR feat(plugin-auth): an OAuth consent binds the consenting session's active organization, carried as organization_id #24218's notes). Under a wall it is dropped unless the user is a member there; if they are, the token acts there, indistinguishably from a consent-bound value. Undersinglethere is no wall to drop it against. It needs an administrator's write to a resource row. Observation, carrier: none.Session organization claim dropped … session=followed by its unknown placeholder for a token, because the synthetic session has no row id. It names the principal and the organization, and says "The session stays authenticated". That line is core's, read-only for this slice. Observation, carrier: none.[#4127 FINDING, batch 5]comment on the session getter ("Reaches BOTH undeclared shapes") is stale too, becauseIAuthServicedeclaresapi/getApi. It is outside this slice's named surface, so it is not edited. Carrier: none.connect-mcp.mdxtroubleshooting row about403 insufficient_scopeand themcp.zod.tsread-family count stay out of scope: this PR edits neither file (triage6229381872).Cross-lane touches (for the seat to declare)
content/docs/ai/agents.mdx:domain:devx.scripts/engine-double-contract.pinned.json: the gate's ledger, one added row.Generated by Claude Code