Skip to content

fix(runtime): an OAuth MCP agent acts in the organization its consent bound, so the user's organization-scoped grants count - #24229

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24137-oauth-agent-organization
Oct 18, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24137-oauth-agent-organization

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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 record 6232498969 assigns it. The two upstream slices landed first: #24165 (PR #24189, organizationId on IAuthService.verifyMcpAccessToken's result) and #24166 (PR #24218, the consent binding and the organization_id claim). Until this PR nothing at runtime read organizationId.

What changed

  • packages/runtime/src/security/resolve-execution-context.ts
    • getSessionForProvenance builds a verified OAuth token's synthetic session as { user: { id }, session: { userId, activeOrganizationId: organizationId } }. Before, it was { user: { id } }. resolveAuthzContext's session arm reads session.activeOrganizationId and vets it with vetOrganizationClaim on every request. That is the rule a browser session's organization gets, and nothing in packages/core changes. A token with no claim passes undefined there and resolves exactly as before.
    • The local oauthPrincipal type was a hand copy of the verifier's result without organizationId. It is now derived from IAuthService['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.
    • The stale "implemented (auth-manager.ts) and undeclared" comment is replaced: the contract declares the method.
  • 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 (its findOne opens with assertEngineFindOnePredicate), written by check-engine-double-contract.mjs --write.
  • content/docs/ai/agents.mdx: the docs line, including that a former member's agent gets an empty 200 read (cross-lane: domain:devx).
  • .changeset/24137-oauth-token-organization.md: @objectstack/runtime patch, Clause-②: no.

Landing point. Core's OAuthTokenProvenance (assemble-execution-context.ts) is not changed. The organization reaches the envelope as authz.tenantId, the session arm's output, which assembleExecutionContext* copies onto ExecutionContext.tenantId. plugin-security's D10 delegator reads context.tenantId (resolveDelegatorContext). Nothing reads an organization off the provenance object, so it does not need to carry one. No packages/core, packages/spec or packages/plugins/plugin-auth file 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:

  • The user's grants scoped to any other organization. Pinned: bound to org_alpha, an org_beta grant is refused at D10; bound to org_beta, the reverse. It never gets the union.
  • Under a wall, an organization the user does not belong to. The session arm drops it, core's existing line logs it, and the principal resolves with no organization. That covers a revoked membership. It also covers an organization a sys_oauth_resource.custom_claims row wrote onto the token: the runtime cannot tell such an override from a consent-bound value, and it vets both (H4).
  • A widened global grant. A token with no claim is unchanged: global grants only.
  • The organization the browser session selects later. The token's organization comes only from the verified token.

Posture table (H3). What the token's organization does in each posture, and whether a REST session naming the same organization does the same:

Posture Member of the token's organization Not a member (revoked, never, or an override) Same as a REST session? Measured how
isolated kept: grants scoped there apply; Layer 0 is that organization dropped (warn line): global grants only; the read is walled to nothing (empty 200); the tenant write is refused (ADR-0123 D2) yes resolver plus the real SecurityPlugin middleware; token and session parity pinned
group kept: grants scoped there apply dropped: global grants only yes resolver only; parity pinned. Layer 0 under group reads accessible_org_ids (every current membership) for every principal by the posture's design. That half is not measured here
single kept kept as claimed: no membership is read yes resolver plus middleware (the card's composition); parity pinned
no tenancy service kept kept as claimed yes resolver; parity pinned

So "re-checked on every request" means membership-checked under isolated and group only. Under single (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)

  • H1 holds. At b9ac35cbf, the local type { userId; scopes; clientId? } and getSessionForProvenance returning { 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 example expected undefined to be 'org_alpha'. In the PARITY case the same user's browser session resolved org_alpha and the token resolved undefined.
  • H2 holds. With the organization on the synthetic session as activeOrganizationId, the session arm kept a member's organization, and dropped a non-member's with core's organization claim dropped (organization_membership_ended) warn line (principal=u_ex organization=org_alpha). It then re-resolved with no organization. No new branch.
  • H3 measured as in the table above.
  • H4 holds under the enforcing postures. An organizationId naming an organization the user is not a member of is dropped under isolated and group (pinned as a control). Under single or 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.
  • H5. Only the runtime's copy had to carry the member, and it is now derived from the contract. Core's OAuthTokenProvenance does 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).
  • H6 holds. A token with no claim resolves with no organization and is refused at D10 for an organization-scoped grant, as before (pinned under isolated and single).

Pins

The REAL resolveExecutionContext and resolveAuthzContext run over a table double (@objectstack/core resolves to source under this package's vitest alias). The resulting context goes into the REAL SecurityPlugin middleware (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:

  1. POSITIVE: Mia, sales_rep in org_alpha (giving crm_sales_user), token bound to org_alpha. find crm_lead is admitted, walled to org_alpha for both principals, and insert is admitted.
  2. PARITY: the same user's session in org_alpha resolves the same tenantId and accessible_org_ids.
  3. Another organization stays invisible: bound to org_alpha, Mia's org_beta grant is refused at D10. Bound to org_beta, the reverse.
  4. No grant still refuses: Pat in org_alpha is refused at D10.
  5. Revoked membership (ruling item 3): the claim is dropped (one warn line), the read is admitted by a global grant but walled to RLS_DENY_FILTER (empty), and the insert is refused with the ADR-0123 D2 "has no active organization" denial.
  6. Platform admin's global grant unchanged: admitted bound (walled to org_alpha) and unbound (walled to nothing on a business object, as before; ADR-0095 W2). posture stays PLATFORM_ADMIN both ways.
  7. No claim (H6): today's D10 denial, no warn line.
  8. Non-member organization (H4): dropped with the warn line, then the D10 denial.

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 isolated and group and kept under single and 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/runtime has no dependency on @objectstack/mcp. http-dispatcher.mcp-oauth.test.ts fakes the MCP service and the data bridge with no security middleware, so a query_records there would assert nothing about D10.

Ablation (once, fix committed first)

  • Tool: scripts/ablation-replace.mjs on resolve-execution-context.ts. The anchor activeOrganizationId: oauthPrincipal!.organizationId became activeOrganizationId: undefined (anchor 1 to 0; blob bb3e258874d0 to e2777512f5ce). The test imports the source relatively, so there is no dist leg.
  • Result: Tests 13 failed | 2 passed (15).
  • Direction observed: more red than the dispatch's template ("controls stay green").
    • Every control except the claim-less ones asserts that the vetting happened: the bound tenantId, or the dropped-claim line. So they go red with the positive case.
    • The two that stayed green are the claim-less ones (isolated and single), the only pins independent of the organization.
    • The refusal halves of the controls still refuse under ablation, but each control's first assertion is the organization it was vetted in.
  • Restore: blob equals HEAD (bb3e258874d0), git diff HEAD empty (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.
  • The pin file alone: Tests 15 passed (15). With resolve-execution-context.test.ts and http-dispatcher.mcp-oauth.test.ts: Test Files 3 passed (3), Tests 63 passed (63).
  • pnpm exec eslint --no-inline-config over the two changed .ts files: exit 0, no output.
  • Builds: turbo run build --filter='@objectstack/runtime^...' gave 30 of 30 tasks. turbo run build --filter='!@objectstack/docs' --concurrency=2 gave 76 of 76 tasks, for the two gates that read every package's dist.
  • Not run locally: test:repo (CI's Test Core). The integration tier rule is packages/cli's and does not apply.

Gates (final head ee089fe5e)

  • Derived: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths; merge base b9ac35cbf; 5 paths, +440 / -9) gave 100 commands.
  • All 100 ran, each exit code captured before any pipe, and all 100 answered 0.
  • --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).
  • Selected verdict lines:
    • 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-loads and check:skill-examples exit 0 after the full build. On an earlier head (before the full build) both answered PREREQUISITE NOT MET.
  • The gate script whose ledger this diff edits: node scripts/check-engine-double-contract.mjs --self-test exit 0.
  • Everything else (Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core, dogfood) is CI's, read from this PR's check runs.

Acceptance notes

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

… 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
…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
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 17, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 3 documentable anchor(s).

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/kernel/contracts/auth-service.mdx (via verifyMcpAccessToken (literal, a string literal in VerifiedMcpAccessToken))
  • content/docs/protocol/kernel/index.mdx (via resolveExecutionContext (symbol, a top-level function))
What this run could not see
  • the SDK route bridge reached 54 of 206 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 — 26 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 9040945d09154481565b9992b07e83273a0fb61e → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 74c9af0182c33a5fb9457f54397fbcfd70f52531 — the merge of head ee089fe5e49e7b0ba87c5c197d0ef3f418383acc into base 9040945d09154481565b9992b07e83273a0fb61e, 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 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

⚠️ 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 9040945d09154481565b9992b07e83273a0fb61e → 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

Security review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ee089fe5e49e7b0ba87c5c197d0ef3f418383acc
Local-runs: none
Scope: card #24137 (body and all 11 comments: triage bound 6229381872, ruling 6232069920, split 6232498969, spec review notes 6237306208, #24166 dev notes 6240513333, dev report 6243083647); PR #24229 body, file list and net diff origin/main...ee089fe5e (5 files, +440/-9); PR #24189 and PR #24218 bodies and file lists; on the head: packages/runtime/src/security/resolve-execution-context.ts, packages/runtime/src/security/resolve-execution-context.oauth-organization.test.ts, content/docs/ai/agents.mdx, .changeset/24137-oauth-token-organization.md; on origin/main (d3d064447): packages/core/src/security/resolve-authz-context.ts (the API-key arm, the session arm at :543-:566, the #15409 block at :712-:732, vetOrganizationClaim :768-:776, grantAppliesInTenant :861, resolveUserAuthzGrants), packages/core/src/security/assemble-execution-context.ts, packages/core/src/security/admission-tenancy-posture.ts, packages/core/src/security/api-key.ts (effectiveTenancyPosture), packages/plugins/plugin-auth/src/auth-manager.ts (consentOrganizationOf, the oauthProvider options, verifyMcpAccessToken), packages/plugins/plugin-security/src/explain-engine.ts (buildContextForUser, resolveDelegatorContext), packages/plugins/plugin-security/src/tenant-layer.ts, packages/plugins/plugin-security/src/objects/default-permission-sets.ts, packages/plugins/plugin-security/src/managed-object-write-denies.ts, packages/platform-objects/src/identity/sys-oauth-resource.object.ts, packages/runtime/src/http-dispatcher.ts (the /mcp door, the environment-membership gate, resolveActiveOrganizationId), packages/runtime/src/api-exposure.ts, packages/spec/src/data/api-derivation.ts, packages/spec/src/contracts/auth-service.ts, packages/runtime/src/security/security-catalog.testkit.ts; the lockfile-pinned @better-auth/oauth-provider@1.7.3 dist (introspect-CbhhXT0E.mjs resolveAccessTokenClaims, getResourceCustomClaims, RESERVED_ACCESS_TOKEN_CLAIMS; authorize-9whjxVLJ.mjs the /admin/oauth2/resources endpoints and assertResourcePrivileges), read from a sibling worktree's node_modules; the 38 check-runs on the head.

① Boundary

Before. getSessionForProvenance answered { user: { id } }. The session arm read session.activeOrganizationId as undefined, so tenantId stayed unset, resolveUserAuthzGrants kept only global rows (grantAppliesInTenant: !organizationId || organizationId === tenantId), and resolveDelegatorContext resolved the D10 delegator with liveTenantId undefined. An ordinary user's agent held nothing; a platform admin's agent held its global grant and, under isolated, was walled to RLS_DENY_FILTER on every business object.

After. The synthetic session carries session.activeOrganizationId = verified.organizationId, and nothing else changes in the path. Traced on origin/main:

  1. resolveAuthzContextInScope :550 takes tenantId ?? sessionData.session.activeOrganizationId. The synthetic getter ignores its headers argument, so the only input is the verifier's result; no header, cookie or request parameter reaches it.
  2. :601 resolves grants IN that tenant; :712-:732 vets the claim against the sys_member rows just read (vetOrganizationClaim), drops it under isolated/group when no current membership backs it, warns, and RE-resolves with no tenant (so org_user_ids is recomputed too). Under single or with no posture the rule returns the claim unread, as it does for a browser session.
  3. The envelope's tenantId is authz.tenantId, the vetted value (assembleExecutionContext entryFields); isSystem is hard-coded false; principalKind is 'agent' only when azp is present and the delegator is authz.userId.
  4. The D10 middleware calls resolveDelegatorContext(ql, context), which reads context.tenantId and resolves the delegator's grants in that organization only; accessible_org_ids is the delegator's own memberships, never inherited.

Measured against the ruling's text. One organization, the consent-bound one: yes, the claim is the consent's reference_id minted by customAccessTokenClaims, and a refresh carries the family's own reference, never the session's (PR #24218, pinned there). Re-checked every request through the existing session path: yes, the #15409 block runs on every request. Never the union: the grant resolution is grantAppliesInTenant against one tenantId. Never the browser session's current organization: the dispatcher's only raw session read (http-dispatcher.ts :1657-:1690, the environment-membership gate) uses the cookie session, which an OAuth-only request does not carry, and resolveActiveOrganizationId reads the vetted executionContext.tenantId. Never a widened global grant: no global row is added anywhere. Gone membership: dropped under a wall, re-resolved with no organization. No claim: activeOrganizationId: undefined, byte-for-byte today's resolution.

Places the token could act elsewhere or keep acting, each judged:

  • group posture read reach. vetOrganizationClaim keeps the claim while the user is a member, grants are resolved in the bound organization, but Layer 0 under group is organization_id IN accessible_org_ids (tenant-layer.ts :143-:151) for EVERY principal, the delegator's own membership set included. A token bound to org_alpha for a user who is also in org_beta therefore reads org_beta rows under group, with org_alpha's grants. That is ADR-0105 D2's design for the same user's browser session, parity is pinned at the resolver level, and the PR body names the middleware half as unmeasured. It is the session path the ruling asked for, not a widening by this diff. Observation.
  • single and no-posture membership loss. The claim stands unread, so a former member's token keeps acting in the bound organization with whatever grant rows survive the membership's end (the u_ex fixture shape: a sys_user_position row in org_alpha, no sys_member row). The ruling's item 3 says the token "falls back to no organization" and item 2 fixes the mechanism as "the existing session path"; under single that path has no wall, so item 3's fall-back is the walled postures' behaviour. The same user's session behaves identically there (pinned: BOUNDARY under single, and the four-posture parity case), and the docs, the changeset and the PR body all state the qualifier. Within the ruling; the seat should be aware the open-core default does not re-check membership for tokens any more than it does for sessions.
  • Unusable engine. :591 returns the context with the unvetted tenantId and empty grants when ql.find is not a function. Pre-existing for sessions; with no grants on either leg the D10 intersection admits nothing. Not a widening.
  • API key beside a JWT bearer. Step 1 admits the key first, sets userId, and the session arm is skipped, so the token's organizationId is never read; the key's own vetted organization governs. The pre-existing cross-credential composition (agent ceiling from the token's scopes, delegator from the key's owner) is untouched by this diff and outside its scope. Not a widening.
  • Writing the organization from the request. consentOrganizationOf reads the session the provider hands it and nothing else; the client's organization_id-shaped authorize parameters change nothing (pinned in PR feat(plugin-auth): an OAuth consent binds the consenting session's active organization, carried as organization_id #24218). The verifier refuses a present-but-malformed claim. Unreachable.

② Attack paths

(a) An organization_id the user did not consent to, via sys_oauth_resource.custom_claims. The provider merges extensionClaims, then perRequestClaims, then the plugin's customAccessTokenClaims, then resourcePolicyClaims (resolveAccessTokenClaims, introspect-CbhhXT0E.mjs :239-:262), and resourcePolicyClaims come from the live oauthResource rows (getResourceCustomClaims :591-:607). organization_id is not in RESERVED_ACCESS_TOKEN_CLAIMS, so a row's value does replace the consent's on every MCP token. Who can write that row: the REST data door is closed for every verb (sys-oauth-resource.object.ts :167 apiMethods: [], which resolveEffectiveApiMethods resolves to deny-all, api-derivation.ts :301, answered 405 by checkApiExposure; the platform admin's wildcard does not reopen it); the MCP data bridge runs the same checkApiExposure gate through callData; the provider's own PATCH /admin/oauth2/resources/:identifier is SERVER_ONLY (in-process auth.api only, and plugin-auth passes no resourcePrivileges); every default write-granting set carries a deny on managedBy: 'better-auth' objects. What remains is an operator with store access or in-process code. If such an operator writes it: under isolated/group the runtime keeps the override only when the user is a current member there (then the token acts there, indistinguishable from consent) and drops it otherwise (pinned, H4 org_gamma); under single or no posture it stands as a session's claim would, which retargets only which organization's grant rows apply, with no wall to cross. Unreachable from any public door; bounded by the vetting under a wall; the residue is an operator's own power.

(b) A revoked or never-held membership. isolated/group: dropped on every request with the warn line, grants re-resolved tenantless; pinned for the revoked member (u_ex: tenantId undefined, one warn line, the read walled to RLS_DENY_FILTER on both conjuncts, the insert refused with the ADR-0123 D2 denial) and for the never-held organization (org_gamma: dropped, then the D10 denial). Unreachable under a wall. single/no posture: kept, as for a session, pinned both ways. Reachable there exactly as for a browser session, by the ruling's choice of mechanism and documented.

(c) The platform-admin global grant. derivePosture reads held capability grants, never the organization; a global admin_full_access row applies with or without a tenant. Bound: posture PLATFORM_ADMIN, read walled to the bound organization on both conjuncts. Unbound: PLATFORM_ADMIN, walled to nothing on a business object under isolated (ADR-0095 W2), as before. The claim cannot mint PLATFORM_ADMIN, and the grant is not widened. Unchanged; pinned both ways.

(d) A token with no claim. activeOrganizationId: undefined, so tenantId is undefined and no warn line is written; the card's D10 denial stands. Today's behaviour; pinned under isolated and single.

(e) The synthetic session trusted without vetting. The session object is a closure local to resolveExecutionContext, consumed only by resolveAuthzContext's session arm; nothing else in the runtime or dispatcher reads it. ctx.tenantId is written at :588 before the vetting at :712, and the only exit between them is the unusable-engine branch judged above. The API-key arm pre-empts the session arm entirely, so a key's organization can never be overwritten by the token's. isSystem is false by construction. The dispatcher's two other organization reads are the cookie session (absent on an OAuth request) and the vetted envelope. Unreachable.

(f) Docs and changeset. agents.mdx states the one-organization rule, the claim name, the wall qualifier on the re-check, the single behaviour ("stands as consented, as a session's does"), the empty 200 and refused write under isolated, and the pre-binding token's global-only grants until one re-consent. The changeset says the same and names the API-key and session tracks as untouched. Each sentence matches a line of code or a pin read here. No overstatement found.

③ Pins

The rig runs the REAL resolveExecutionContext into the REAL resolveAuthzContext (core resolved to source by the package's vitest alias) over a table double, and hands the envelope to the REAL SecurityPlugin middleware; only verifyMcpAccessToken is faked, and its half is pinned against a real authorization server in PR #24218. Vacuity checks: the { posture } double is read by effectiveTenancyPosture(tenancy.posture), so the posture reaches both the resolver and the wall; bindCatalogFromTables makes the catalog the resolver reads real; droppedClaimLines matches the exact text warnSessionOrganizationClaimDropped prints, principal and organization included; the positive case asserts the real Layer 0 composition ([{ organization_id: org_alpha }, { organization_id: org_alpha }]) and an admitted insert, not merely tenantId. The ruling's four controls are each present and discriminating (the other-organization control is asserted in both directions, so it excludes the union rather than only the absence of grants). The claim-less token, the non-member organization, the single composition and the four-posture token/session parity are pinned. Gaps, both named in the PR body: no middleware-level pin under group; no pin for the API-key-beside-JWT composition (pre-existing, out of scope).

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 tenantId, or the dropped-claim line), and only then asserts the refusal. That ordering is what makes the controls non-vacuous: with the organization removed, "refused at D10" is true for any organization-scoped grant, so a refusal-only control would pass under ablation and prove nothing about the boundary. The two pure negatives (claim-less, isolated and single) stayed green, which is the template's "controls stay green" expectation applied to the controls it fits. The dev read the direction correctly.

④ Flags

  1. Resource custom_claims override, out of scope. Agree. Unreachable from any public door (②a), vetted under a wall by the mechanism the ruling fixes, and under single it is a store-level operator's own power. A hardening follow-up belongs to domain:services (have the authorization server ignore or refuse organization_id arriving from a resource row, or pin the seeded MCP resource row to carry no custom_claims), not this PR.
  2. The dropped-claim warn line for a token. Agree. Core's line, read-only for this slice; it names the principal and the organization, and prints its unknown placeholder for the session id because the synthetic session has no row. Cosmetic; a later core card could carry the token's azp on that line. Not a gap.
  3. The stale [#4127 FINDING, batch 5] comment. Agree. Prose on the session getter, outside this slice's surface, no security content.

Check-runs on the head, read at 2026-10-17T23:41Z: 38. success: Build Core, Build Docs, Governed Surface Queue Guard, Check Changeset, Check PR Size, Lint · ESLint, Lint · PM dispatch-gates, Lint · repo & tooling gates, Lint · CI release & engine gates, Lint · docs & code guards, Lint · scoped · slot lookup & corpus, Lint · scoped · tooling self-tests, Type Check · source gates, Type Check · debt ledger, Spec property liveness, Check Documentation Links, Flag docs affected by code changes, the three claim guards, Part-of PR must not also close its card, Auto Label, filter, Dogfood Regression Gate (aggregate). in_progress: Test Core 1/6 through 6/6, Type Check · workspace, Type Check · consumer gates, Lint · scoped · query-options erasure. skipped (main-only battery): the three dogfood shards, Dogfood Verify CLI, Console Pin Gate, Temporal Conformance, Packed-tarball smoke. No failure read. The required Test Core and TypeScript Type Check contexts were not yet concluded at this reading; this record does not vouch for them.

Implemented-by: claude/issue-24137-oauth-agent-organization
Reviewed-by: session_01QyoWKK9RwUNxBxmevy9Neb

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 — ACCEPT · PR #24229 at ee089fe5e (#24137)

domain:cli seat 2 · marchtian · session session_01QyoWKK9RwUNxBxmevy9Neb · 2026-10-17T23:42Z · holder of claim 6242006177 · Ruling-ref: 6232069920. The dev's report is comment 6243083647 on #24137. The isolated adversarial review of record is 6243231799 on this PR (## Security review, CONTRACT_REVIEW_TIER, PASS), and the seat reviewed against this PR, not the report's own account.

  1. Shape. Draft → main. The first line is Fixes #24137, the only closing keyword. Clause-②: no and Ruling-ref: 6232069920 are line-initial, and the assignee is marchtian. 5 files:

    check-governed-merges.mjs --pr 24229: NOT governed, 449 changed lines.

  2. The fix, read on the diff.

  3. Against the ruling 6232069920, item by item (the security review traces each on origin/main):

    • one organization, the consent-bound one: yes;
    • re-checked every request through the existing session path: yes;
    • never the union, never the browser session's organization, never a widened global grant: none found;
    • a gone membership → no organization under a wall: pinned;
    • a claim-less token → today's behaviour: pinned under isolated and single;
    • the four named controls: each present and discriminating. The other-organization control is asserted in both directions.
  4. Observations the seat carries to the maintainer's round report (both match the same user's browser session; they are the mechanism the ruling chose, not a widening):

    • under group, Layer 0 reads every organization the user belongs to, with the bound organization's grants;
    • under single or with no tenancy service, a former member's token keeps its organization, as their session would.

    The docs and changeset state the posture qualifiers.

  5. Pins and ablation. 15 cases drive the real resolver into the real SecurityPlugin middleware; only the verifier is faked, and PR feat(plugin-auth): an OAuth consent binds the consenting session's active organization, carried as organization_id #24218 pins its half against a real authorization server. The dev reported "Tests 11 failed | 1 passed (12)" before the fix. Ablating the organization reddens 13 of 15: every control first asserts the organization it was vetted in, and only the two claim-less negatives stay green. That ordering is what keeps the controls from passing vacuously, and the review confirms it. The full runtime local suite: 410 files, 6295 passed. 100 derived gates, 100 run, all exit 0.

  6. Clause-②: no, read. This is a runtime permission behaviour change, not a published accept set: the organizationId member was added by spec(contracts): the verified MCP access token result carries organizationId, the organization an OAuth consent bound (slice 1 of #24137, ruled A at 6232069920) #24165 (Clause-②: yes, reviewed there). Per the claim, the security review stands in for a contract-review face, and none is touched.
    Changeset and docs, read sentence by sentence against the diff and the review:

    • "What was wrong" quotes the card's measured denial.
    • "What changed … vetted on every request by the rule a browser session's organization gets" matches the session arm.
    • "A membership that is gone … under isolated … an empty 200 read and a refused tenant write … Under single … stands as claimed" matches the pins.
    • "Unchanged … a token with no organization_id … global grants only" matches H6.

    The agents.mdx paragraph says the same, with no overstatement (review ②f).

  7. Out of scope:

    • the resource-row custom_claims override: unreachable from any public door (sys_oauth_resource has apiMethods: [], and the provider's admin route is server-only), and dropped under a wall unless the user is a member. Acceptance notes, with a possible domain:services hardening. Not filed: it fails filing gate ①'s public-door reach;
    • the dropped-claim warn line's wording for a token: cosmetic, core's. Acceptance notes;
    • the stale [#4127 FINDING, batch 5] comment: Acceptance notes.
  8. Landing. Ordinary queue landing once every check on ee089fe5e is green or a roster skip; Test Core and two Type Check lanes were in progress at the review's reading. Fixes #24137 closes the card, and the seat strips pm:dispatched in the same act.


Generated by Claude Code

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/m tests tooling

Projects

None yet

2 participants