Skip to content

Commit 7702d27

Browse files
committed
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb
1 parent b9ac35c commit 7702d27

2 files changed

Lines changed: 407 additions & 8 deletions

File tree

Lines changed: 375 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,375 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* An OAuth access token acts in the ONE organization its consent bound
5+
* (ADR-0090 D10; ruling record 6232069920, letter A).
6+
*
7+
* ## The defect
8+
*
9+
* `resolveExecutionContext` built a verified MCP token's session as
10+
* `{ user: { id } }` — no organization at all. So the shared resolver derived
11+
* no tenant, and the D10 delegator (the user the agent acts for, resolved by
12+
* `plugin-security` in the request's organization) held its GLOBAL grants and
13+
* nothing else. Every grant an administrator assigns is scoped to an
14+
* organization (ADR-0131), so an ordinary user's agent was refused every
15+
* object operation: `on-behalf-of principal may not 'find' … the delegator
16+
* lacks that grant (ADR-0090 D10 intersection)`.
17+
*
18+
* ## The rule now
19+
*
20+
* The verifier returns the organization the consent bound
21+
* (`IAuthService.verifyMcpAccessToken` → `organizationId`, read from the
22+
* token's `organization_id` claim). The synthetic session carries it as the
23+
* session's `activeOrganizationId`, so the session arm of
24+
* `resolveAuthzContext` vets it on EVERY request with the rule it applies to a
25+
* browser session (`vetOrganizationClaim`): kept while a current membership
26+
* backs it under a walled posture, dropped (no organization, global grants
27+
* only) once none does. ⛔ No union of the user's organizations, no widened
28+
* global grant, no third branch in core.
29+
*
30+
* ## The rig
31+
*
32+
* The REAL `resolveExecutionContext` → `resolveAuthzContext` (this package's
33+
* vitest alias resolves `@objectstack/core` to its source) over a table double,
34+
* and the resulting context handed to the REAL `SecurityPlugin` middleware —
35+
* the layer that answered the card's denial — over the same tables, under the
36+
* `isolated` posture (the enforcing one). Only the token verifier is faked: its
37+
* own half (consent → claim → `organizationId`) is pinned against a real
38+
* authorization server in `@objectstack/plugin-auth`.
39+
*/
40+
41+
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest';
42+
import type { PermissionSet } from '@objectstack/spec/security';
43+
import type { ExecutionContext } from '@objectstack/spec/kernel';
44+
import { SecurityPlugin, RLS_DENY_FILTER } from '@objectstack/plugin-security';
45+
46+
import { resolveExecutionContext } from './resolve-execution-context.js';
47+
import { bindCatalogFromTables } from './security-catalog.testkit.js';
48+
49+
const ALPHA = 'org_alpha';
50+
const BETA = 'org_beta';
51+
/** An organization no fixture user belongs to. */
52+
const GAMMA = 'org_gamma';
53+
const CLIENT = 'agent-client-1';
54+
const LEAD = 'crm_lead';
55+
const CONTACT = 'crm_contact';
56+
57+
/** One source for every set: the catalog rows core reads and the bodies the security plugin reads. */
58+
const SETS: PermissionSet[] = [
59+
{ name: 'crm_sales_user', label: 'Sales user', objects: { [LEAD]: { allowRead: true, allowCreate: true } } },
60+
{ name: 'crm_contact_reader', label: 'Contact reader', objects: { [CONTACT]: { allowRead: true } } },
61+
{ name: 'crm_lead_global', label: 'Lead (global)', objects: { [LEAD]: { allowRead: true, allowCreate: true } } },
62+
{ name: 'admin_full_access', label: 'Admin', objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } } },
63+
] as PermissionSet[];
64+
65+
/**
66+
* - `u_mia` holds `sales_rep` in org_alpha (→ `crm_sales_user`) and
67+
* `crm_contact_reader` in org_beta; a member of both — the "another
68+
* organization" control.
69+
* - `u_pat` is an org_alpha member holding no grant.
70+
* - `u_admin` holds `admin_full_access` GLOBALLY (the platform-admin row).
71+
* - `u_ex` held `sales_rep` in org_alpha and was removed from it (no
72+
* org_alpha `sys_member` row; still a member of org_beta). They also hold
73+
* `crm_lead_global` globally, so what is left after the claim is dropped is
74+
* visible: a read the wall empties and a write it refuses.
75+
*/
76+
function tables(): Record<string, any[]> {
77+
return {
78+
sys_api_key: [],
79+
sys_user: ['u_mia', 'u_pat', 'u_admin', 'u_ex'].map((id) => ({ id, email: `${id}@example.com` })),
80+
sys_member: [
81+
{ user_id: 'u_mia', organization_id: ALPHA, role: 'member' },
82+
{ user_id: 'u_mia', organization_id: BETA, role: 'member' },
83+
{ user_id: 'u_pat', organization_id: ALPHA, role: 'member' },
84+
{ user_id: 'u_admin', organization_id: ALPHA, role: 'owner' },
85+
{ user_id: 'u_ex', organization_id: BETA, role: 'member' },
86+
],
87+
sys_user_position: [
88+
{ user_id: 'u_mia', position: 'sales_rep', organization_id: ALPHA },
89+
{ user_id: 'u_ex', position: 'sales_rep', organization_id: ALPHA },
90+
],
91+
sys_user_permission_set: [
92+
{ user_id: 'u_mia', permission_set: 'crm_contact_reader', organization_id: BETA },
93+
{ user_id: 'u_admin', permission_set: 'admin_full_access', organization_id: null },
94+
{ user_id: 'u_ex', permission_set: 'crm_lead_global', organization_id: null },
95+
],
96+
sys_permission_set: SETS.map((s) => ({ id: `ps_${s.name}`, name: s.name, object_permissions: JSON.stringify(s.objects ?? {}) })),
97+
sys_position: [{ id: 'pos_sales_rep', name: 'sales_rep', organization_id: null }],
98+
sys_position_permission_set: [{ position_id: 'pos_sales_rep', permission_set_id: 'ps_crm_sales_user' }],
99+
};
100+
}
101+
102+
/** Equality, `$in` and a NULL column; every other shape refused loudly. */
103+
function matchesWhere(row: any, where: any): boolean {
104+
return Object.entries(where ?? {}).every(([field, cond]) => {
105+
if (field.startsWith('$')) throw new Error(`fixture where-matcher: unsupported combinator '${field}'`);
106+
if (cond !== null && typeof cond === 'object') {
107+
if (!Array.isArray((cond as any).$in)) throw new Error(`fixture where-matcher: unsupported operator on '${field}'`);
108+
return (cond as any).$in.includes(row[field]);
109+
}
110+
if (cond === null) return (row[field] ?? null) === null;
111+
return row[field] === cond;
112+
});
113+
}
114+
115+
function schemaOf(name: string) {
116+
return {
117+
name,
118+
sharingModel: 'public_read_write',
119+
fields: { id: { name: 'id' }, name: { name: 'name', type: 'text' }, organization_id: { name: 'organization_id', type: 'text' } },
120+
};
121+
}
122+
123+
/** The verified results the fake verifier answers, by bearer. */
124+
type Verified = { userId: string; scopes: string[]; clientId?: string; organizationId?: string };
125+
126+
function rig(posture: 'isolated' | 'group' | 'single' | undefined) {
127+
const t = tables();
128+
let middleware: any;
129+
const ql: any = bindCatalogFromTables({
130+
registerMiddleware: (mw: any) => { if (!middleware) middleware = mw; },
131+
getSchema: (name: string) => (name.startsWith('sys_') ? undefined : schemaOf(name)),
132+
async find(object: string, q: any = {}) {
133+
const rows = (t[object] ?? []).filter((r) => matchesWhere(r, q?.where));
134+
return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows;
135+
},
136+
async findOne(object: string, q: any = {}) {
137+
return (t[object] ?? []).filter((r) => matchesWhere(r, q?.where))[0] ?? null;
138+
},
139+
}, t);
140+
141+
const tokens = new Map<string, Verified>();
142+
const services: Record<string, any> = {
143+
manifest: { register: vi.fn() },
144+
objectql: ql,
145+
metadata: {
146+
get: async (_type: string, name: string) => (name.startsWith('sys_') ? null : schemaOf(name)),
147+
list: async () => SETS,
148+
},
149+
auth: { verifyMcpAccessToken: async (bearer: string) => tokens.get(bearer) ?? null },
150+
...(posture ? { tenancy: { posture } } : {}),
151+
};
152+
153+
const resolve = async (verified: Verified): Promise<ExecutionContext> => {
154+
const bearer = `h.${tokens.size}.s`;
155+
tokens.set(bearer, verified);
156+
return resolveExecutionContext({
157+
acceptOAuthAccessToken: true,
158+
getService: async (name: string) => services[name],
159+
getQl: async () => ql,
160+
request: { headers: { authorization: `Bearer ${bearer}` } },
161+
});
162+
};
163+
164+
/** The same user, signed in through a browser session naming `activeOrganizationId` — the REST door's shape. */
165+
const resolveSession = async (userId: string, activeOrganizationId: string) =>
166+
resolveExecutionContext({
167+
getService: async (name: string) => (name === 'auth'
168+
? { api: { getSession: async () => ({ user: { id: userId }, session: { id: `ses_${userId}`, userId, activeOrganizationId } }) } }
169+
: services[name]),
170+
getQl: async () => ql,
171+
request: { headers: {} },
172+
});
173+
174+
const boot = async () => {
175+
const plugin = new SecurityPlugin({ fallbackPermissionSet: null });
176+
const ctx: any = {
177+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
178+
registerService: vi.fn(),
179+
getService: (name: string) => {
180+
if (!(name in services)) throw new Error(`service not registered: ${name}`);
181+
return services[name];
182+
},
183+
};
184+
await plugin.init(ctx);
185+
await plugin.start(ctx);
186+
};
187+
188+
/** Run one operation through the security middleware; the engine body stands in for `next()`. */
189+
const run = async (context: ExecutionContext, operation: 'find' | 'insert', object: string) => {
190+
const opCtx: any = operation === 'find'
191+
? { object, operation, ast: { where: undefined }, context }
192+
: { object, operation, data: { name: 'written' }, context };
193+
await middleware(opCtx, async () => {
194+
const seam = opCtx.postHookWriteImageCheck;
195+
if (seam) {
196+
seam.honoured = true;
197+
await seam.evaluate([opCtx.data]);
198+
}
199+
if (operation === 'find') opCtx.result = [];
200+
});
201+
return opCtx;
202+
};
203+
204+
return { resolve, resolveSession, boot, run };
205+
}
206+
207+
const READ_TOKEN = (userId: string, organizationId?: string): Verified => ({
208+
userId,
209+
scopes: ['data:read', 'data:write'],
210+
clientId: CLIENT,
211+
...(organizationId ? { organizationId } : {}),
212+
});
213+
214+
/**
215+
* The composed read filter's conjuncts. D10 ANDs the agent's Layer 0 with the
216+
* DELEGATOR's, so a delegated read under a wall carries one per principal.
217+
*/
218+
const conjuncts = (where: any): unknown[] => (where && Array.isArray(where.$and) ? where.$and : [where]);
219+
220+
/** The card's message: the D10 intersection refusing the delegator's missing grant. */
221+
const D10_DENIAL = /on-behalf-of principal may not '(find|insert)' .* the delegator lacks that grant \(ADR-0090 D10 intersection\)/;
222+
223+
let warn: ReturnType<typeof vi.spyOn>;
224+
beforeEach(() => { warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); });
225+
afterEach(() => { warn.mockRestore(); });
226+
227+
const droppedClaimLines = () => warn.mock.calls
228+
.map((c) => String(c[0]))
229+
.filter((line) => line.includes('organization claim dropped (organization_membership_ended)'));
230+
231+
describe('an OAuth token acts in the organization its consent bound — isolated posture, the real D10 middleware', () => {
232+
const r = rig('isolated');
233+
beforeAll(async () => { await r.boot(); });
234+
235+
it('POSITIVE: the token bound to org_alpha reads and creates with the organization-scoped grant the user holds there', async () => {
236+
const ctx = await r.resolve(READ_TOKEN('u_mia', ALPHA));
237+
expect(ctx.tenantId).toBe(ALPHA);
238+
expect(ctx.principalKind).toBe('agent');
239+
expect(ctx.onBehalfOf).toEqual({ userId: 'u_mia', principalKind: 'human' });
240+
241+
const read = await r.run(ctx, 'find', LEAD);
242+
// Admitted, and walled to the bound organization alone.
243+
expect(conjuncts(read.ast.where)).toEqual([{ organization_id: ALPHA }, { organization_id: ALPHA }]);
244+
await expect(r.run(ctx, 'insert', LEAD)).resolves.toBeDefined();
245+
expect(droppedClaimLines()).toEqual([]);
246+
});
247+
248+
it('PARITY: the same user over a browser session in org_alpha resolves the same organization', async () => {
249+
const viaToken = await r.resolve(READ_TOKEN('u_mia', ALPHA));
250+
const viaSession = await r.resolveSession('u_mia', ALPHA);
251+
expect(viaToken.tenantId).toBe(viaSession.tenantId);
252+
expect(viaToken.accessible_org_ids).toEqual(viaSession.accessible_org_ids);
253+
expect(viaSession.permissions).toContain('crm_sales_user');
254+
});
255+
256+
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 () => {
257+
const ctx = await r.resolve(READ_TOKEN('u_mia', ALPHA));
258+
await expect(r.run(ctx, 'find', CONTACT)).rejects.toThrow(D10_DENIAL);
259+
260+
// Bound to org_beta instead: the reverse, never the union.
261+
const beta = await r.resolve(READ_TOKEN('u_mia', BETA));
262+
expect(beta.tenantId).toBe(BETA);
263+
const contacts = await r.run(beta, 'find', CONTACT);
264+
expect(conjuncts(contacts.ast.where)).toEqual([{ organization_id: BETA }, { organization_id: BETA }]);
265+
await expect(r.run(beta, 'find', LEAD)).rejects.toThrow(D10_DENIAL);
266+
});
267+
268+
it('CONTROL — no grant still refuses: an org_alpha member holding nothing is refused in org_alpha', async () => {
269+
const ctx = await r.resolve(READ_TOKEN('u_pat', ALPHA));
270+
expect(ctx.tenantId).toBe(ALPHA);
271+
await expect(r.run(ctx, 'find', LEAD)).rejects.toThrow(D10_DENIAL);
272+
});
273+
274+
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 () => {
275+
const ctx = await r.resolve(READ_TOKEN('u_ex', ALPHA));
276+
expect(ctx.tenantId).toBeUndefined();
277+
expect(droppedClaimLines()).toHaveLength(1);
278+
expect(droppedClaimLines()[0]).toContain(`principal=u_ex organization=${ALPHA}`);
279+
280+
// The global grant admits the read; the wall has no organization to read in.
281+
const read = await r.run(ctx, 'find', LEAD);
282+
expect(conjuncts(read.ast.where)).toEqual([RLS_DENY_FILTER, RLS_DENY_FILTER]);
283+
await expect(r.run(ctx, 'insert', LEAD)).rejects.toThrow(/has no active organization/);
284+
});
285+
286+
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 () => {
287+
const bound = await r.resolve(READ_TOKEN('u_admin', ALPHA));
288+
expect(bound.tenantId).toBe(ALPHA);
289+
expect(bound.posture).toBe('PLATFORM_ADMIN');
290+
expect(conjuncts((await r.run(bound, 'find', LEAD)).ast.where)).toEqual([{ organization_id: ALPHA }, { organization_id: ALPHA }]);
291+
292+
// Unbound is what every token resolved before this change: still admitted
293+
// by the global grant, and — a business object under `isolated` — walled
294+
// to nothing with no organization to read in (ADR-0095 W2), as before.
295+
const unbound = await r.resolve(READ_TOKEN('u_admin'));
296+
expect(unbound.tenantId).toBeUndefined();
297+
expect(unbound.posture).toBe('PLATFORM_ADMIN');
298+
expect(conjuncts((await r.run(unbound, 'find', LEAD)).ast.where)).toEqual([RLS_DENY_FILTER, RLS_DENY_FILTER]);
299+
});
300+
301+
it('CONTROL — a token minted before consents bound an organization (no claim) keeps today\'s behaviour: global grants only', async () => {
302+
const ctx = await r.resolve(READ_TOKEN('u_mia'));
303+
expect(ctx.tenantId).toBeUndefined();
304+
await expect(r.run(ctx, 'find', LEAD)).rejects.toThrow(D10_DENIAL);
305+
expect(droppedClaimLines()).toEqual([]);
306+
});
307+
308+
it('CONTROL — an organization the user does not belong to (a resource row\'s custom claim, say) is dropped, never honoured', async () => {
309+
const ctx = await r.resolve(READ_TOKEN('u_mia', GAMMA));
310+
expect(ctx.tenantId).toBeUndefined();
311+
expect(droppedClaimLines()).toHaveLength(1);
312+
expect(droppedClaimLines()[0]).toContain(`principal=u_mia organization=${GAMMA}`);
313+
await expect(r.run(ctx, 'find', LEAD)).rejects.toThrow(D10_DENIAL);
314+
});
315+
});
316+
317+
/**
318+
* The open-core default composition the card measured (`single`: no wall).
319+
* The token's organization still decides which organization-scoped grants
320+
* count, and — no wall, no membership read — it stands as claimed, exactly
321+
* as a browser session's `activeOrganizationId` does there.
322+
*/
323+
describe('the same token under the single posture (no wall) — the real D10 middleware', () => {
324+
const r = rig('single');
325+
beforeAll(async () => { await r.boot(); });
326+
327+
it('POSITIVE: the token bound to org_alpha reads with the organization-scoped grant (the card\'s measured case)', async () => {
328+
const ctx = await r.resolve(READ_TOKEN('u_mia', ALPHA));
329+
expect(ctx.tenantId).toBe(ALPHA);
330+
expect((await r.run(ctx, 'find', LEAD)).ast.where).toBeUndefined();
331+
await expect(r.run(ctx, 'insert', LEAD)).resolves.toBeDefined();
332+
});
333+
334+
it('CONTROL — no claim: the card\'s denial, unchanged', async () => {
335+
const ctx = await r.resolve(READ_TOKEN('u_mia'));
336+
expect(ctx.tenantId).toBeUndefined();
337+
await expect(r.run(ctx, 'find', LEAD)).rejects.toThrow(D10_DENIAL);
338+
});
339+
340+
it('BOUNDARY — a former member\'s token keeps the bound organization here, as their session would: no wall reads no membership', async () => {
341+
const viaToken = await r.resolve(READ_TOKEN('u_ex', ALPHA));
342+
const viaSession = await r.resolveSession('u_ex', ALPHA);
343+
expect(viaToken.tenantId).toBe(ALPHA);
344+
expect(viaSession.tenantId).toBe(ALPHA);
345+
expect(droppedClaimLines()).toEqual([]);
346+
await expect(r.run(viaToken, 'find', LEAD)).resolves.toBeDefined();
347+
});
348+
});
349+
350+
/**
351+
* The vetting is the session arm's, so the token's organization answers
352+
* exactly what a browser session's does in every posture: vetted against the
353+
* user's memberships where a wall is enforced (`isolated`, `group`), kept as
354+
* claimed where none is (`single`, or no `tenancy` service) — the same claim
355+
* a REST session's `activeOrganizationId` keeps there.
356+
*/
357+
describe('the token\'s organization follows the session arm in every posture', () => {
358+
const CASES: Array<['isolated' | 'group' | 'single' | undefined, { member: string | undefined; stranger: string | undefined }]> = [
359+
['isolated', { member: ALPHA, stranger: undefined }],
360+
['group', { member: ALPHA, stranger: undefined }],
361+
['single', { member: ALPHA, stranger: GAMMA }],
362+
[undefined, { member: ALPHA, stranger: GAMMA }],
363+
];
364+
for (const [posture, expected] of CASES) {
365+
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 () => {
366+
const r = rig(posture);
367+
for (const [org, want] of [[ALPHA, expected.member], [GAMMA, expected.stranger]] as const) {
368+
const viaToken = await r.resolve(READ_TOKEN('u_mia', org));
369+
const viaSession = await r.resolveSession('u_mia', org);
370+
expect(viaToken.tenantId, `token bound to ${org}`).toBe(want);
371+
expect(viaSession.tenantId, `session naming ${org}`).toBe(want);
372+
}
373+
});
374+
}
375+
});

0 commit comments

Comments
 (0)