Skip to content

Commit 5a9a80b

Browse files
fix(runtime): an OAuth MCP agent acts in the organization its consent bound, so the user's organization-scoped grants count (#24229)
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 #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 - **`main` moved two commits after the base** (`9040945d0`, PR #24225 for the plugin-auth verifier's keyring read, and PR #24224 for a rest route move). Neither touches this diff's files, `packages/core` or the verifier's result shape. Not merged; CI runs on the merge ref. - **An override inside the user's memberships is honoured.** An administrator-written `sys_oauth_resource.custom_claims` naming `organization_id` outranks the consent's value (the provider's merge order, per PR #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. Under `single` there is no wall to drop it against. It needs an administrator's write to a resource row. Observation, carrier: none. - **The dropped-claim line names no client.** Core prints `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. - **Another stale comment.** The `[#4127 FINDING, batch 5]` comment on the session getter ("Reaches BOTH undeclared shapes") is stale too, because `IAuthService` declares `api` / `getApi`. It is outside this slice's named surface, so it is not edited. Carrier: none. - **Out of scope here.** The `connect-mcp.mdx` troubleshooting row about `403 insufficient_scope` and the `mcp.zod.ts` read-family count stay out of scope: this PR edits neither file (triage `6229381872`). ## 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](https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6e43b7b commit 5a9a80b

5 files changed

Lines changed: 440 additions & 9 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): an OAuth MCP agent acts in the organization the user's consent bound, so the user's organization-scoped grants count for it
6+
7+
Clause-②: no
8+
9+
- **What was wrong.** On the `/mcp` door, `resolveExecutionContext` built a verified OAuth access token's principal with no organization. The agent's delegator (the user it acts for, ADR-0090 D10) was then resolved with no active organization and held only its global grants. Every grant an administrator assigns is scoped to an organization, so an ordinary user's agent was refused every object operation: `on-behalf-of principal may not 'find' … the delegator lacks that grant (ADR-0090 D10 intersection)`.
10+
- **What changed.** The principal is resolved in the organization the consent bound: the `organizationId` that `IAuthService.verifyMcpAccessToken` returns from the token's `organization_id` claim. It is handed to the shared resolver where a session carries its active organization, so it is vetted on every request by the rule a browser session's organization gets (`vetOrganizationClaim`, `@objectstack/core`). The user's grants scoped to that organization count for the agent; grants scoped to the user's other organizations do not. It is never the union of the user's organizations, never the organization the user's browser session has selected now, and never a widened global grant.
11+
- **A membership that is gone.** Under a walled tenancy posture (`isolated`, `group`), a token whose organization the user no longer belongs to resolves with no organization, so only global grants apply. Under `isolated`, a former member's agent gets an empty `200` read and a refused tenant write. The same holds for an organization the user never belonged to, whatever put it on the token. Under `single`, or with no `tenancy` service, the organization stands as claimed, exactly as a browser session's does there.
12+
- **Unchanged.** A token with no `organization_id` (minted before consents bound one, or by a user with no active organization at consent) resolves with no organization, as before: global grants only, until the user re-confirms the consent once. A platform admin's global grant applies as before. The API-key and session tracks are untouched.

‎content/docs/ai/agents.mdx‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,20 @@ SKILL.md download (`GET /api/v1/mcp/skill`), and API-key minting.
6565
private deployments work out of the box**. You authorize the client in the
6666
browser; it then acts as an **agent on your behalf** (`principalKind: 'agent'`),
6767
its **data access** bounded by the **intersection** of the consent scopes and
68-
your own permissions/RLS — it can never exceed either. For object CRUD the
68+
your own permissions/RLS — it can never exceed either. The agent acts in
69+
**one organization**: the one you had selected when you consented, carried
70+
in the token as its `organization_id` claim. Your grants scoped to that
71+
organization count for it; grants scoped to your other organizations do not
72+
(select the other organization and authorize the client again to work
73+
there). Your membership is re-checked on every request by the rule a
74+
browser session's organization gets: under an organization wall (`isolated`
75+
or `group` tenancy) a token whose organization you no longer belong to falls
76+
back to **no organization**, so only your global grants apply — under
77+
`isolated`, a former member's agent gets an **empty `200`** on reads and a
78+
refusal on writes to organization data. Under `single` (no wall) the
79+
organization stands as consented, as a session's does. A token issued
80+
before consents carried an organization holds your global grants only until
81+
you re-confirm the consent once. For object CRUD the
6982
consent scopes (`data:read`, `data:write`) are a real ceiling, not just a
7083
tool-family filter: they narrow the exposed tools **and** cap what the agent
7184
can do at the data layer — a `data:read` token can never write a record even

0 commit comments

Comments
 (0)