From 7702d27058a4e23d19badc2fcf4671f07433d43e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 22:34:34 +0000 Subject: [PATCH 1/4] fix(runtime): an OAuth MCP token acts in the organization its consent 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 Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb --- ...ecution-context.oauth-organization.test.ts | 375 ++++++++++++++++++ .../src/security/resolve-execution-context.ts | 40 +- 2 files changed, 407 insertions(+), 8 deletions(-) create mode 100644 packages/runtime/src/security/resolve-execution-context.oauth-organization.test.ts diff --git a/packages/runtime/src/security/resolve-execution-context.oauth-organization.test.ts b/packages/runtime/src/security/resolve-execution-context.oauth-organization.test.ts new file mode 100644 index 00000000000..c87e8f5df7b --- /dev/null +++ b/packages/runtime/src/security/resolve-execution-context.oauth-organization.test.ts @@ -0,0 +1,375 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * An OAuth access token acts in the ONE organization its consent bound + * (ADR-0090 D10; ruling record 6232069920, letter A). + * + * ## The defect + * + * `resolveExecutionContext` built a verified MCP token's session as + * `{ user: { id } }` — no organization at all. So the shared resolver derived + * no tenant, and the D10 delegator (the user the agent acts for, resolved by + * `plugin-security` in the request's organization) held its GLOBAL grants and + * nothing else. Every grant an administrator assigns is scoped to an + * organization (ADR-0131), 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)`. + * + * ## The rule now + * + * The verifier returns the organization the consent bound + * (`IAuthService.verifyMcpAccessToken` → `organizationId`, read from the + * token's `organization_id` claim). The synthetic session carries it as the + * session's `activeOrganizationId`, so the session arm of + * `resolveAuthzContext` vets it on EVERY request with the rule it applies to a + * browser session (`vetOrganizationClaim`): kept while a current membership + * backs it under a walled posture, dropped (no organization, global grants + * only) once none does. ⛔ No union of the user's organizations, no widened + * global grant, no third branch in core. + * + * ## The rig + * + * The REAL `resolveExecutionContext` → `resolveAuthzContext` (this package's + * vitest alias resolves `@objectstack/core` to its source) over a table double, + * and the resulting context handed to the REAL `SecurityPlugin` middleware — + * the layer that answered the card's denial — over the same tables, under the + * `isolated` posture (the enforcing one). Only the token verifier is faked: its + * own half (consent → claim → `organizationId`) is pinned against a real + * authorization server in `@objectstack/plugin-auth`. + */ + +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'; +import type { PermissionSet } from '@objectstack/spec/security'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { SecurityPlugin, RLS_DENY_FILTER } from '@objectstack/plugin-security'; + +import { resolveExecutionContext } from './resolve-execution-context.js'; +import { bindCatalogFromTables } from './security-catalog.testkit.js'; + +const ALPHA = 'org_alpha'; +const BETA = 'org_beta'; +/** An organization no fixture user belongs to. */ +const GAMMA = 'org_gamma'; +const CLIENT = 'agent-client-1'; +const LEAD = 'crm_lead'; +const CONTACT = 'crm_contact'; + +/** One source for every set: the catalog rows core reads and the bodies the security plugin reads. */ +const SETS: PermissionSet[] = [ + { name: 'crm_sales_user', label: 'Sales user', objects: { [LEAD]: { allowRead: true, allowCreate: true } } }, + { name: 'crm_contact_reader', label: 'Contact reader', objects: { [CONTACT]: { allowRead: true } } }, + { name: 'crm_lead_global', label: 'Lead (global)', objects: { [LEAD]: { allowRead: true, allowCreate: true } } }, + { name: 'admin_full_access', label: 'Admin', objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } } }, +] as PermissionSet[]; + +/** + * - `u_mia` holds `sales_rep` in org_alpha (→ `crm_sales_user`) and + * `crm_contact_reader` in org_beta; a member of both — the "another + * organization" control. + * - `u_pat` is an org_alpha member holding no grant. + * - `u_admin` holds `admin_full_access` GLOBALLY (the platform-admin row). + * - `u_ex` held `sales_rep` in org_alpha and was removed from it (no + * org_alpha `sys_member` row; still a member of org_beta). They also hold + * `crm_lead_global` globally, so what is left after the claim is dropped is + * visible: a read the wall empties and a write it refuses. + */ +function tables(): Record { + return { + sys_api_key: [], + sys_user: ['u_mia', 'u_pat', 'u_admin', 'u_ex'].map((id) => ({ id, email: `${id}@example.com` })), + sys_member: [ + { user_id: 'u_mia', organization_id: ALPHA, role: 'member' }, + { user_id: 'u_mia', organization_id: BETA, role: 'member' }, + { user_id: 'u_pat', organization_id: ALPHA, role: 'member' }, + { user_id: 'u_admin', organization_id: ALPHA, role: 'owner' }, + { user_id: 'u_ex', organization_id: BETA, role: 'member' }, + ], + sys_user_position: [ + { user_id: 'u_mia', position: 'sales_rep', organization_id: ALPHA }, + { user_id: 'u_ex', position: 'sales_rep', organization_id: ALPHA }, + ], + sys_user_permission_set: [ + { user_id: 'u_mia', permission_set: 'crm_contact_reader', organization_id: BETA }, + { user_id: 'u_admin', permission_set: 'admin_full_access', organization_id: null }, + { user_id: 'u_ex', permission_set: 'crm_lead_global', organization_id: null }, + ], + sys_permission_set: SETS.map((s) => ({ id: `ps_${s.name}`, name: s.name, object_permissions: JSON.stringify(s.objects ?? {}) })), + sys_position: [{ id: 'pos_sales_rep', name: 'sales_rep', organization_id: null }], + sys_position_permission_set: [{ position_id: 'pos_sales_rep', permission_set_id: 'ps_crm_sales_user' }], + }; +} + +/** Equality, `$in` and a NULL column; every other shape refused loudly. */ +function matchesWhere(row: any, where: any): boolean { + return Object.entries(where ?? {}).every(([field, cond]) => { + if (field.startsWith('$')) throw new Error(`fixture where-matcher: unsupported combinator '${field}'`); + if (cond !== null && typeof cond === 'object') { + if (!Array.isArray((cond as any).$in)) throw new Error(`fixture where-matcher: unsupported operator on '${field}'`); + return (cond as any).$in.includes(row[field]); + } + if (cond === null) return (row[field] ?? null) === null; + return row[field] === cond; + }); +} + +function schemaOf(name: string) { + return { + name, + sharingModel: 'public_read_write', + fields: { id: { name: 'id' }, name: { name: 'name', type: 'text' }, organization_id: { name: 'organization_id', type: 'text' } }, + }; +} + +/** The verified results the fake verifier answers, by bearer. */ +type Verified = { userId: string; scopes: string[]; clientId?: string; organizationId?: string }; + +function rig(posture: 'isolated' | 'group' | 'single' | undefined) { + const t = tables(); + let middleware: any; + const ql: any = bindCatalogFromTables({ + registerMiddleware: (mw: any) => { if (!middleware) middleware = mw; }, + getSchema: (name: string) => (name.startsWith('sys_') ? undefined : schemaOf(name)), + async find(object: string, q: any = {}) { + const rows = (t[object] ?? []).filter((r) => matchesWhere(r, q?.where)); + return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; + }, + async findOne(object: string, q: any = {}) { + return (t[object] ?? []).filter((r) => matchesWhere(r, q?.where))[0] ?? null; + }, + }, t); + + const tokens = new Map(); + const services: Record = { + manifest: { register: vi.fn() }, + objectql: ql, + metadata: { + get: async (_type: string, name: string) => (name.startsWith('sys_') ? null : schemaOf(name)), + list: async () => SETS, + }, + auth: { verifyMcpAccessToken: async (bearer: string) => tokens.get(bearer) ?? null }, + ...(posture ? { tenancy: { posture } } : {}), + }; + + const resolve = async (verified: Verified): Promise => { + const bearer = `h.${tokens.size}.s`; + tokens.set(bearer, verified); + return resolveExecutionContext({ + acceptOAuthAccessToken: true, + getService: async (name: string) => services[name], + getQl: async () => ql, + request: { headers: { authorization: `Bearer ${bearer}` } }, + }); + }; + + /** The same user, signed in through a browser session naming `activeOrganizationId` — the REST door's shape. */ + const resolveSession = async (userId: string, activeOrganizationId: string) => + resolveExecutionContext({ + getService: async (name: string) => (name === 'auth' + ? { api: { getSession: async () => ({ user: { id: userId }, session: { id: `ses_${userId}`, userId, activeOrganizationId } }) } } + : services[name]), + getQl: async () => ql, + request: { headers: {} }, + }); + + const boot = async () => { + const plugin = new SecurityPlugin({ fallbackPermissionSet: null }); + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + await plugin.init(ctx); + await plugin.start(ctx); + }; + + /** Run one operation through the security middleware; the engine body stands in for `next()`. */ + const run = async (context: ExecutionContext, operation: 'find' | 'insert', object: string) => { + const opCtx: any = operation === 'find' + ? { object, operation, ast: { where: undefined }, context } + : { object, operation, data: { name: 'written' }, context }; + await middleware(opCtx, async () => { + const seam = opCtx.postHookWriteImageCheck; + if (seam) { + seam.honoured = true; + await seam.evaluate([opCtx.data]); + } + if (operation === 'find') opCtx.result = []; + }); + return opCtx; + }; + + return { resolve, resolveSession, boot, run }; +} + +const READ_TOKEN = (userId: string, organizationId?: string): Verified => ({ + userId, + scopes: ['data:read', 'data:write'], + clientId: CLIENT, + ...(organizationId ? { organizationId } : {}), +}); + +/** + * The composed read filter's conjuncts. D10 ANDs the agent's Layer 0 with the + * DELEGATOR's, so a delegated read under a wall carries one per principal. + */ +const conjuncts = (where: any): unknown[] => (where && Array.isArray(where.$and) ? where.$and : [where]); + +/** The card's message: the D10 intersection refusing the delegator's missing grant. */ +const D10_DENIAL = /on-behalf-of principal may not '(find|insert)' .* the delegator lacks that grant \(ADR-0090 D10 intersection\)/; + +let warn: ReturnType; +beforeEach(() => { warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); }); +afterEach(() => { warn.mockRestore(); }); + +const droppedClaimLines = () => warn.mock.calls + .map((c) => String(c[0])) + .filter((line) => line.includes('organization claim dropped (organization_membership_ended)')); + +describe('an OAuth token acts in the organization its consent bound — isolated posture, the real D10 middleware', () => { + const r = rig('isolated'); + beforeAll(async () => { await r.boot(); }); + + it('POSITIVE: the token bound to org_alpha reads and creates with the organization-scoped grant the user holds there', async () => { + const ctx = await r.resolve(READ_TOKEN('u_mia', ALPHA)); + expect(ctx.tenantId).toBe(ALPHA); + expect(ctx.principalKind).toBe('agent'); + expect(ctx.onBehalfOf).toEqual({ userId: 'u_mia', principalKind: 'human' }); + + const read = await r.run(ctx, 'find', LEAD); + // Admitted, and walled to the bound organization alone. + expect(conjuncts(read.ast.where)).toEqual([{ organization_id: ALPHA }, { organization_id: ALPHA }]); + await expect(r.run(ctx, 'insert', LEAD)).resolves.toBeDefined(); + expect(droppedClaimLines()).toEqual([]); + }); + + it('PARITY: the same user over a browser session in org_alpha resolves the same organization', async () => { + const viaToken = await r.resolve(READ_TOKEN('u_mia', ALPHA)); + const viaSession = await r.resolveSession('u_mia', ALPHA); + expect(viaToken.tenantId).toBe(viaSession.tenantId); + expect(viaToken.accessible_org_ids).toEqual(viaSession.accessible_org_ids); + expect(viaSession.permissions).toContain('crm_sales_user'); + }); + + it('CONTROL — another organization stays invisible: bound to org_alpha, the org_beta grant does not count and the wall names org_alpha alone', async () => { + const ctx = await r.resolve(READ_TOKEN('u_mia', ALPHA)); + await expect(r.run(ctx, 'find', CONTACT)).rejects.toThrow(D10_DENIAL); + + // Bound to org_beta instead: the reverse, never the union. + const beta = await r.resolve(READ_TOKEN('u_mia', BETA)); + expect(beta.tenantId).toBe(BETA); + const contacts = await r.run(beta, 'find', CONTACT); + expect(conjuncts(contacts.ast.where)).toEqual([{ organization_id: BETA }, { organization_id: BETA }]); + await expect(r.run(beta, 'find', LEAD)).rejects.toThrow(D10_DENIAL); + }); + + it('CONTROL — no grant still refuses: an org_alpha member holding nothing is refused in org_alpha', async () => { + const ctx = await r.resolve(READ_TOKEN('u_pat', ALPHA)); + expect(ctx.tenantId).toBe(ALPHA); + await expect(r.run(ctx, 'find', LEAD)).rejects.toThrow(D10_DENIAL); + }); + + it('CONTROL — a revoked membership: the claim is dropped, so only global grants apply — the read comes back empty and the tenant write is refused loudly', async () => { + const ctx = await r.resolve(READ_TOKEN('u_ex', ALPHA)); + expect(ctx.tenantId).toBeUndefined(); + expect(droppedClaimLines()).toHaveLength(1); + expect(droppedClaimLines()[0]).toContain(`principal=u_ex organization=${ALPHA}`); + + // The global grant admits the read; the wall has no organization to read in. + const read = await r.run(ctx, 'find', LEAD); + expect(conjuncts(read.ast.where)).toEqual([RLS_DENY_FILTER, RLS_DENY_FILTER]); + await expect(r.run(ctx, 'insert', LEAD)).rejects.toThrow(/has no active organization/); + }); + + it('CONTROL — a platform admin\'s global grant is unchanged: it admits the delegated read bound or unbound; only the organization the wall reads in moves', async () => { + const bound = await r.resolve(READ_TOKEN('u_admin', ALPHA)); + expect(bound.tenantId).toBe(ALPHA); + expect(bound.posture).toBe('PLATFORM_ADMIN'); + expect(conjuncts((await r.run(bound, 'find', LEAD)).ast.where)).toEqual([{ organization_id: ALPHA }, { organization_id: ALPHA }]); + + // Unbound is what every token resolved before this change: still admitted + // by the global grant, and — a business object under `isolated` — walled + // to nothing with no organization to read in (ADR-0095 W2), as before. + const unbound = await r.resolve(READ_TOKEN('u_admin')); + expect(unbound.tenantId).toBeUndefined(); + expect(unbound.posture).toBe('PLATFORM_ADMIN'); + expect(conjuncts((await r.run(unbound, 'find', LEAD)).ast.where)).toEqual([RLS_DENY_FILTER, RLS_DENY_FILTER]); + }); + + it('CONTROL — a token minted before consents bound an organization (no claim) keeps today\'s behaviour: global grants only', async () => { + const ctx = await r.resolve(READ_TOKEN('u_mia')); + expect(ctx.tenantId).toBeUndefined(); + await expect(r.run(ctx, 'find', LEAD)).rejects.toThrow(D10_DENIAL); + expect(droppedClaimLines()).toEqual([]); + }); + + it('CONTROL — an organization the user does not belong to (a resource row\'s custom claim, say) is dropped, never honoured', async () => { + const ctx = await r.resolve(READ_TOKEN('u_mia', GAMMA)); + expect(ctx.tenantId).toBeUndefined(); + expect(droppedClaimLines()).toHaveLength(1); + expect(droppedClaimLines()[0]).toContain(`principal=u_mia organization=${GAMMA}`); + await expect(r.run(ctx, 'find', LEAD)).rejects.toThrow(D10_DENIAL); + }); +}); + +/** + * The open-core default composition the card measured (`single`: no wall). + * The token's organization still decides which organization-scoped grants + * count, and — no wall, no membership read — it stands as claimed, exactly + * as a browser session's `activeOrganizationId` does there. + */ +describe('the same token under the single posture (no wall) — the real D10 middleware', () => { + const r = rig('single'); + beforeAll(async () => { await r.boot(); }); + + it('POSITIVE: the token bound to org_alpha reads with the organization-scoped grant (the card\'s measured case)', async () => { + const ctx = await r.resolve(READ_TOKEN('u_mia', ALPHA)); + expect(ctx.tenantId).toBe(ALPHA); + expect((await r.run(ctx, 'find', LEAD)).ast.where).toBeUndefined(); + await expect(r.run(ctx, 'insert', LEAD)).resolves.toBeDefined(); + }); + + it('CONTROL — no claim: the card\'s denial, unchanged', async () => { + const ctx = await r.resolve(READ_TOKEN('u_mia')); + expect(ctx.tenantId).toBeUndefined(); + await expect(r.run(ctx, 'find', LEAD)).rejects.toThrow(D10_DENIAL); + }); + + it('BOUNDARY — a former member\'s token keeps the bound organization here, as their session would: no wall reads no membership', async () => { + const viaToken = await r.resolve(READ_TOKEN('u_ex', ALPHA)); + const viaSession = await r.resolveSession('u_ex', ALPHA); + expect(viaToken.tenantId).toBe(ALPHA); + expect(viaSession.tenantId).toBe(ALPHA); + expect(droppedClaimLines()).toEqual([]); + await expect(r.run(viaToken, 'find', LEAD)).resolves.toBeDefined(); + }); +}); + +/** + * The vetting is the session arm's, so the token's organization answers + * exactly what a browser session's does in every posture: vetted against the + * user's memberships where a wall is enforced (`isolated`, `group`), kept as + * claimed where none is (`single`, or no `tenancy` service) — the same claim + * a REST session's `activeOrganizationId` keeps there. + */ +describe('the token\'s organization follows the session arm in every posture', () => { + const CASES: Array<['isolated' | 'group' | 'single' | undefined, { member: string | undefined; stranger: string | undefined }]> = [ + ['isolated', { member: ALPHA, stranger: undefined }], + ['group', { member: ALPHA, stranger: undefined }], + ['single', { member: ALPHA, stranger: GAMMA }], + [undefined, { member: ALPHA, stranger: GAMMA }], + ]; + for (const [posture, expected] of CASES) { + it(`${posture ?? 'no tenancy service'}: a member organization ${expected.member ? 'is kept' : 'is dropped'}; a non-member one ${expected.stranger ? 'is kept' : 'is dropped'} — as for a session`, async () => { + const r = rig(posture); + for (const [org, want] of [[ALPHA, expected.member], [GAMMA, expected.stranger]] as const) { + const viaToken = await r.resolve(READ_TOKEN('u_mia', org)); + const viaSession = await r.resolveSession('u_mia', org); + expect(viaToken.tenantId, `token bound to ${org}`).toBe(want); + expect(viaSession.tenantId, `session naming ${org}`).toBe(want); + } + }); + } +}); diff --git a/packages/runtime/src/security/resolve-execution-context.ts b/packages/runtime/src/security/resolve-execution-context.ts index 561c4612fe9..bb3e258874d 100644 --- a/packages/runtime/src/security/resolve-execution-context.ts +++ b/packages/runtime/src/security/resolve-execution-context.ts @@ -27,7 +27,7 @@ */ import type { ExecutionContext } from '@objectstack/spec/kernel'; -import type { ServiceSlotContract, ServiceSlotContracts } from '@objectstack/spec/contracts'; +import type { IAuthService, ServiceSlotContract, ServiceSlotContracts } from '@objectstack/spec/contracts'; import { scopesToAgentPermissionSets, MCP_OAUTH_SCOPE_ACTIONS } from '@objectstack/spec/ai'; import { preferredLocaleFromHeader } from '@objectstack/spec/system'; // [#22258] The in-process session-read rule — see the session getter below. @@ -64,6 +64,14 @@ interface KernelServiceLookup { (name: string): Promise | any; } +/** + * A verified MCP OAuth access token, exactly as `IAuthService` declares the + * verifier's result — derived from the contract rather than copied, so a + * member the contract gains (`organizationId`, the organization the consent + * bound) cannot be dropped here by a hand-written shape. + */ +type VerifiedMcpAccessToken = NonNullable>>>; + interface ResolveOptions { /** Function returning a service from the active kernel (or undefined). */ getService: KernelServiceLookup; @@ -143,18 +151,16 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise Promise | any` - // is a second, smaller instance of the same problem — a lookup facade - // that returns `any` — and typing it is part of that batch too. + // `verifyMcpAccessToken` is declared by `IAuthService` and reached + // through the slot-typed lookup, so the verified result is read as the + // contract states it — `organizationId` included. const authService = await opts.getService('auth'); const verified = await authService?.verifyMcpAccessToken?.(jwtBearer); if (verified?.userId && Array.isArray(verified.scopes)) { @@ -192,8 +198,26 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise ({ user: { id: oauthPrincipal!.userId } }) + ? async () => ({ + user: { id: oauthPrincipal!.userId }, + session: { userId: oauthPrincipal!.userId, activeOrganizationId: oauthPrincipal!.organizationId }, + }) : oauthBearerPresented ? async () => undefined : getSession; From 2a1ad99fb3109ff011b78195dae8745ab69ccc2b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 22:35:32 +0000 Subject: [PATCH 2/4] docs(ai): an OAuth agent acts in the organization its consent bound 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 Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb --- .changeset/24137-oauth-token-organization.md | 12 ++++++++++++ content/docs/ai/agents.mdx | 15 ++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 .changeset/24137-oauth-token-organization.md diff --git a/.changeset/24137-oauth-token-organization.md b/.changeset/24137-oauth-token-organization.md new file mode 100644 index 00000000000..a445b0fa357 --- /dev/null +++ b/.changeset/24137-oauth-token-organization.md @@ -0,0 +1,12 @@ +--- +'@objectstack/runtime': patch +--- + +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 + +Clause-②: no + +- **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)`. +- **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. +- **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. +- **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. diff --git a/content/docs/ai/agents.mdx b/content/docs/ai/agents.mdx index fe41842b49c..79d21e65491 100644 --- a/content/docs/ai/agents.mdx +++ b/content/docs/ai/agents.mdx @@ -65,7 +65,20 @@ SKILL.md download (`GET /api/v1/mcp/skill`), and API-key minting. private deployments work out of the box**. You authorize the client in the browser; it then acts as an **agent on your behalf** (`principalKind: 'agent'`), its **data access** bounded by the **intersection** of the consent scopes and - your own permissions/RLS — it can never exceed either. For object CRUD the + your own permissions/RLS — it can never exceed either. The agent acts in + **one organization**: the one you had selected when you consented, carried + in the token as its `organization_id` claim. Your grants scoped to that + organization count for it; grants scoped to your other organizations do not + (select the other organization and authorize the client again to work + there). Your membership is re-checked on every request by the rule a + browser session's organization gets: under an organization wall (`isolated` + or `group` tenancy) a token whose organization you no longer belong to falls + back to **no organization**, so only your global grants apply — under + `isolated`, a former member's agent gets an **empty `200`** on reads and a + refusal on writes to organization data. Under `single` (no wall) the + organization stands as consented, as a session's does. A token issued + before consents carried an organization holds your global grants only until + you re-confirm the consent once. For object CRUD the consent scopes (`data:read`, `data:write`) are a real ceiling, not just a tool-family filter: they narrow the exposed tools **and** cap what the agent can do at the data layer — a `data:read` token can never write a record even From 4fc42edad64d1f26df1966cc85a4fe146f2cbff3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 22:40:20 +0000 Subject: [PATCH 3/4] test(runtime): type the dropped-claim line reader in the OAuth organization pins Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb --- .../resolve-execution-context.oauth-organization.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/runtime/src/security/resolve-execution-context.oauth-organization.test.ts b/packages/runtime/src/security/resolve-execution-context.oauth-organization.test.ts index c87e8f5df7b..d6b4d22d869 100644 --- a/packages/runtime/src/security/resolve-execution-context.oauth-organization.test.ts +++ b/packages/runtime/src/security/resolve-execution-context.oauth-organization.test.ts @@ -224,9 +224,9 @@ let warn: ReturnType; beforeEach(() => { warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); }); afterEach(() => { warn.mockRestore(); }); -const droppedClaimLines = () => warn.mock.calls - .map((c) => String(c[0])) - .filter((line) => line.includes('organization claim dropped (organization_membership_ended)')); +const droppedClaimLines = (): string[] => warn.mock.calls + .map((c: unknown[]) => String(c[0])) + .filter((line: string) => line.includes('organization claim dropped (organization_membership_ended)')); describe('an OAuth token acts in the organization its consent bound — isolated posture, the real D10 middleware', () => { const r = rig('isolated'); From ee089fe5e49e7b0ba87c5c197d0ef3f418383acc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 22:59:36 +0000 Subject: [PATCH 4/4] test(runtime): the OAuth organization pins' engine double routes findOne 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 Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb --- .../resolve-execution-context.oauth-organization.test.ts | 2 ++ scripts/engine-double-contract.pinned.json | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/packages/runtime/src/security/resolve-execution-context.oauth-organization.test.ts b/packages/runtime/src/security/resolve-execution-context.oauth-organization.test.ts index d6b4d22d869..3ccce48aa9d 100644 --- a/packages/runtime/src/security/resolve-execution-context.oauth-organization.test.ts +++ b/packages/runtime/src/security/resolve-execution-context.oauth-organization.test.ts @@ -42,6 +42,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vite import type { PermissionSet } from '@objectstack/spec/security'; import type { ExecutionContext } from '@objectstack/spec/kernel'; import { SecurityPlugin, RLS_DENY_FILTER } from '@objectstack/plugin-security'; +import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; import { resolveExecutionContext } from './resolve-execution-context.js'; import { bindCatalogFromTables } from './security-catalog.testkit.js'; @@ -134,6 +135,7 @@ function rig(posture: 'isolated' | 'group' | 'single' | undefined) { return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; }, async findOne(object: string, q: any = {}) { + assertEngineFindOnePredicate(object, q); return (t[object] ?? []).filter((r) => matchesWhere(r, q?.where))[0] ?? null; }, }, t); diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 0f9aa1e1bdb..b73c81d9945 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -4141,6 +4141,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/runtime/src/security/resolve-execution-context.oauth-organization.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/runtime/src/seed-loader.test.ts", "verb": "delete",