Skip to content

Commit 6ca6447

Browse files
committed
test(service-automation): probe a runAs:'user' flow's sys_user reach against the request path
The flow's data context carries no org_user_ids, so sys_user_org_members drops out and the run sees only the acting user's row. Probe first; the fix follows. Claude-Session: https://claude.ai/code/session_01LR1cD9JqgkeqY7k9rrZH8s Co-authored-by: Claude <noreply@anthropic.com>
1 parent fb9c22a commit 6ca6447

1 file changed

Lines changed: 168 additions & 0 deletions

File tree

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A `runAs:'user'` flow reads `sys_user` exactly as the triggering user's own
5+
* request does: the acting user and the members of the run's organisation,
6+
* and nothing beyond.
7+
*
8+
* The defect this pins: the run's data context carried no `org_user_ids`.
9+
* `sys_user`'s shipped member scope is two policies, `sys_user_self`
10+
* (`id == current_user.id`) and `sys_user_org_members`
11+
* (`id in current_user.org_user_ids`); the second compiles
12+
* `unresolved-variable` when the peer list is absent and drops out, so the
13+
* run saw only the acting user's own row while the same user's request saw
14+
* their colleagues too. On the request path the peer list is seeded by the
15+
* core resolver (`resolveUserAuthzGrants`, through `resolveAuthzContext`);
16+
* the run now seeds it through the same resolver at run setup.
17+
*
18+
* The REAL stack runs here — ObjectKernel, ObjectQLPlugin, driver-sql on
19+
* better-sqlite3 `:memory:`, the identity objects, the real SecurityPlugin
20+
* with its shipped default permission sets, and the real AutomationEngine and
21+
* CRUD nodes — because the defect lived in the hand-off between the run's
22+
* identity and the RLS compiler, which no single-layer test sees.
23+
*
24+
* Fixture: organisation A (the Default Organization) and organisation B.
25+
* `usr_me` belongs to BOTH and acts in A; `usr_peer` belongs to A;
26+
* `usr_other` belongs to B only; `usr_loner` belongs to none. Acting in A,
27+
* "nothing beyond" means neither `usr_other` (a colleague of `usr_me` in a
28+
* DIFFERENT organisation) nor `usr_loner`.
29+
*/
30+
31+
import { describe, it, expect, afterEach } from 'vitest';
32+
import { ObjectKernel, resolveUserAuthzGrants, type PluginContext } from '@objectstack/core';
33+
import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql';
34+
import { SqlDriver } from '@objectstack/driver-sql';
35+
import { createIdentityObjectsPlugin } from '@objectstack/plugin-auth';
36+
import { SecurityPlugin } from '@objectstack/plugin-security';
37+
import { AutomationServicePlugin } from './plugin.js';
38+
import type { AutomationEngine } from './engine.js';
39+
40+
const SYS = { isSystem: true } as const;
41+
42+
/** start → get_record(sys_user, many) → end, under runAs:'user'; the rows land in the `users` output. */
43+
const directoryFlow = (name: string) => ({
44+
name,
45+
label: name,
46+
type: 'autolaunched',
47+
runAs: 'user',
48+
variables: [{ name: 'users', type: 'object', isOutput: true }],
49+
nodes: [
50+
{ id: 'start', type: 'start', label: 'Start' },
51+
{
52+
id: 'read',
53+
type: 'get_record',
54+
label: 'Read directory',
55+
config: { objectName: 'sys_user', fields: ['id', 'name'], limit: 50, outputVariable: 'users' },
56+
},
57+
{ id: 'end', type: 'end', label: 'End' },
58+
],
59+
edges: [
60+
{ id: 'e1', source: 'start', target: 'read' },
61+
{ id: 'e2', source: 'read', target: 'end' },
62+
],
63+
});
64+
65+
describe("a runAs:'user' flow sees the acting user and their organisation's members, and nothing beyond", () => {
66+
let kernel: ObjectKernel;
67+
let ql: ObjectQL;
68+
let automation: AutomationEngine;
69+
let orgA = '';
70+
const orgB = 'org_b';
71+
72+
afterEach(async () => {
73+
try { await kernel?.shutdown(); } catch { /* noop */ }
74+
});
75+
76+
async function boot() {
77+
kernel = new ObjectKernel({ logger: { level: 'fatal' } });
78+
const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
79+
await kernel.use({
80+
name: 'test.driver.sqlite',
81+
type: 'standard',
82+
version: '1.0.0',
83+
async init(ctx: PluginContext) {
84+
ctx.registerService('driver.default', driver);
85+
},
86+
});
87+
await kernel.use(new ObjectQLPlugin());
88+
await kernel.use(new AutomationServicePlugin({ suspendedRunStore: 'memory' }));
89+
await kernel.use(createIdentityObjectsPlugin());
90+
await kernel.use(new SecurityPlugin());
91+
await kernel.bootstrap();
92+
93+
ql = kernel.getService<ObjectQL>('objectql');
94+
automation = kernel.getService<AutomationEngine>('automation');
95+
96+
const orgs = await ql.find('sys_organization', { context: SYS });
97+
expect(orgs.map((o: any) => o.slug)).toEqual(['default']);
98+
orgA = String(orgs[0].id);
99+
await ql.insert('sys_organization', { id: orgB, name: 'Organisation B', slug: 'org-b' }, { context: SYS });
100+
101+
for (const [id, name] of [['usr_me', 'Me'], ['usr_peer', 'Peer'], ['usr_other', 'Other'], ['usr_loner', 'Loner']]) {
102+
await ql.insert('sys_user', { id, name, email: `${id}@example.test` }, { context: SYS });
103+
}
104+
const memberships: Array<[string, string, string]> = [
105+
['mem_me_a', 'usr_me', orgA],
106+
['mem_me_b', 'usr_me', orgB],
107+
['mem_peer_a', 'usr_peer', orgA],
108+
['mem_other_b', 'usr_other', orgB],
109+
];
110+
for (const [id, userId, organizationId] of memberships) {
111+
await ql.insert('sys_member', { id, user_id: userId, organization_id: organizationId, role: 'member' }, { context: SYS });
112+
}
113+
}
114+
115+
/**
116+
* The context the request path hands ObjectQL for `userId` acting in
117+
* `tenantId`: `resolveAuthzContext` copies exactly these grants onto it.
118+
*/
119+
async function requestContext(userId: string, tenantId?: string) {
120+
const grants = await resolveUserAuthzGrants(ql, userId, { tenantId, bypassGrantsCache: true });
121+
return {
122+
userId,
123+
...(tenantId ? { tenantId } : {}),
124+
isSystem: false,
125+
positions: grants.positions,
126+
permissions: grants.permissions,
127+
systemPermissions: grants.systemPermissions,
128+
org_user_ids: grants.org_user_ids,
129+
accessible_org_ids: grants.accessible_org_ids,
130+
};
131+
}
132+
133+
const ids = (rows: unknown) => (Array.isArray(rows) ? rows.map((r: any) => String(r.id)).sort() : rows);
134+
135+
async function runDirectory(trigger: Record<string, unknown>) {
136+
automation.registerFlow('directory', directoryFlow('directory') as any);
137+
const res = await automation.execute('directory', trigger as any);
138+
expect(res.success, `run failed: ${JSON.stringify(res)}`).toBe(true);
139+
return ids((res.output as { users?: unknown } | undefined)?.users);
140+
}
141+
142+
it("CONTROL — the user's own request sees themself and organisation A's members, and nothing beyond", async () => {
143+
await boot();
144+
const rows = await ql.find('sys_user', { fields: ['id', 'name'], context: await requestContext('usr_me', orgA) });
145+
expect(ids(rows)).toEqual(['usr_me', 'usr_peer']);
146+
});
147+
148+
it('a run triggered with the record-change shape ({ userId, tenantId }) answers what the request answers', async () => {
149+
await boot();
150+
expect(await runDirectory({ userId: 'usr_me', tenantId: orgA })).toEqual(['usr_me', 'usr_peer']);
151+
});
152+
153+
it("a run triggered with the doors' pre-resolved shape (positions and permissions already present) answers the same", async () => {
154+
await boot();
155+
const req = await requestContext('usr_me', orgA);
156+
expect(req.permissions.length, 'the doors forward permissions only when non-empty').toBeGreaterThan(0);
157+
expect(
158+
await runDirectory({ userId: 'usr_me', tenantId: orgA, positions: req.positions, permissions: req.permissions }),
159+
).toEqual(['usr_me', 'usr_peer']);
160+
});
161+
162+
it('a run with no organisation answers what an organisation-less request answers: the acting user alone', async () => {
163+
await boot();
164+
const rows = await ql.find('sys_user', { fields: ['id', 'name'], context: await requestContext('usr_me') });
165+
expect(ids(rows)).toEqual(['usr_me']);
166+
expect(await runDirectory({ userId: 'usr_me' })).toEqual(['usr_me']);
167+
});
168+
});

0 commit comments

Comments
 (0)