Repository navigation
Commit 5a9a80b
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
- .changeset
- content/docs/ai
- packages/runtime/src/security
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
65 | 65 | | |
66 | 66 | | |
67 | 67 | | |
68 | | - | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
69 | 82 | | |
70 | 83 | | |
71 | 84 | | |
| |||
0 commit comments