Skip to content

Commit bf72f62

Browse files
committed
test(plugin-security): measurement probe for the organization-less position-name fold
Records, over a real ObjectQL + SqlDriver, which sys_permission_set rows the permission-set loader returns for a principal with no active organization, and whether their capabilities reach the resolved sets and the effective object map. Readings only; converted into the pin once the fix lands. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1c761c0 commit bf72f62

1 file changed

Lines changed: 203 additions & 0 deletions

File tree

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20555] MEASUREMENT PROBE — what an organization-less principal's
5+
* position-name fold resolves, over a real engine.
6+
*
7+
* Probe stage: records readings only (to the file named by
8+
* `OS_TEST_PROBE_20555_OUT` when set). Converted into the pin once measured.
9+
*/
10+
11+
import { writeFileSync } from 'node:fs';
12+
import { describe, it, expect, afterEach } from 'vitest';
13+
import { ObjectQL } from '@objectstack/objectql';
14+
import { SqlDriver } from '@objectstack/driver-sql';
15+
16+
import { SysPosition } from './objects/sys-position.object.js';
17+
import { SysPermissionSet } from './objects/sys-permission-set.object.js';
18+
import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js';
19+
import { SysUserPosition } from './objects/sys-user-position.object.js';
20+
import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js';
21+
import { SysOrganization, SysUser, SysMember } from '@objectstack/platform-objects/identity';
22+
import {
23+
assertEngineUpdateDispatch,
24+
assertEngineFindOnePredicate,
25+
assertEngineDeleteDispatch,
26+
} from '@objectstack/metadata-core';
27+
28+
import { SecurityPlugin } from './security-plugin.js';
29+
import { buildContextForUser } from './explain-engine.js';
30+
31+
const SYS = { context: { isSystem: true } } as any;
32+
const ORG_A = 'org_a_author';
33+
const ORG_B = 'org_b_home';
34+
const USER_B = 'usr_b_member';
35+
/** Org-less principal holding GLOBAL grants only (A4: what must keep resolving). */
36+
const USER_G = 'usr_g_global';
37+
38+
const PROBE_OBJECT: any = {
39+
name: 'probe_ledger',
40+
label: 'Probe Ledger',
41+
fields: {
42+
id: { type: 'text', label: 'Id', primary: true },
43+
name: { type: 'text', label: 'Name' },
44+
owner_id: { type: 'text', label: 'Owner' },
45+
},
46+
};
47+
48+
const FULL = { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, viewAllRecords: true, modifyAllRecords: true };
49+
50+
const engines: ObjectQL[] = [];
51+
afterEach(async () => {
52+
while (engines.length) {
53+
try { await engines.pop()?.destroy(); } catch { /* noop */ }
54+
}
55+
});
56+
57+
async function insertSet(e: any, id: string, name: string, organizationId: string | null, caps: string[]) {
58+
await e.insert('sys_permission_set', {
59+
id,
60+
name,
61+
label: `${organizationId ?? 'GLOBAL'} ${name}`,
62+
organization_id: organizationId,
63+
managed_by: 'admin',
64+
active: true,
65+
object_permissions: JSON.stringify({ probe_ledger: FULL }),
66+
system_permissions: JSON.stringify(caps),
67+
}, SYS);
68+
}
69+
70+
async function boot(): Promise<ObjectQL> {
71+
const engine = new ObjectQL();
72+
engine.registerDriver(
73+
new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }),
74+
true,
75+
);
76+
await engine.init();
77+
engine.registerApp({
78+
id: 'com.objectstack.orgless-position-fold-20555',
79+
name: 'Org-less position fold',
80+
version: '1.0.0',
81+
type: 'plugin',
82+
scope: 'system',
83+
objects: [
84+
SysPosition, SysPermissionSet, SysPositionPermissionSet,
85+
SysUserPosition, SysUserPermissionSet,
86+
SysOrganization, SysUser, SysMember, PROBE_OBJECT,
87+
],
88+
} as any);
89+
await engine.syncSchemas();
90+
engines.push(engine);
91+
const e = engine as any;
92+
await e.insert('sys_organization', { id: ORG_A, name: ORG_A, slug: 'a' }, SYS);
93+
await e.insert('sys_organization', { id: ORG_B, name: ORG_B, slug: 'b' }, SYS);
94+
await e.insert('sys_user', { id: USER_B, name: 'b', email: 'b@example.test' }, SYS);
95+
await e.insert('sys_user', { id: USER_G, name: 'g', email: 'g@example.test' }, SYS);
96+
// Both principals: members of ORG_B only — never of ORG_A.
97+
await e.insert('sys_member', { id: 'mem_b', user_id: USER_B, organization_id: ORG_B, role: 'member' }, SYS);
98+
await e.insert('sys_member', { id: 'mem_g', user_id: USER_G, organization_id: ORG_B, role: 'member' }, SYS);
99+
// ORG_A authors permission sets whose NAMES spell names the principals hold as positions.
100+
await insertSet(e, 'ps_a_org_member', 'org_member', ORG_A, ['manage_metadata', 'probe.cap.a_org_member']);
101+
await insertSet(e, 'ps_a_everyone', 'everyone', ORG_A, ['manage_metadata', 'probe.cap.a_everyone']);
102+
103+
// A4 — the GLOBAL grants USER_G really holds:
104+
// (1) a global position assignment folded onto a global same-named set;
105+
await e.insert('sys_user_position', { id: 'up_g_ops', user_id: USER_G, position: 'ops_lead', organization_id: null }, SYS);
106+
await insertSet(e, 'ps_global_ops_lead', 'ops_lead', null, ['probe.cap.global_ops_lead']);
107+
// (2) a global user grant to a global DB-authored set — but ORG_A holds a
108+
// same-named copy, inserted FIRST, so an unordered read meets it first.
109+
await insertSet(e, 'ps_a_platform_ops', 'platform_ops', ORG_A, ['probe.cap.a_platform_ops']);
110+
await insertSet(e, 'ps_global_platform_ops', 'platform_ops', null, ['probe.cap.global_platform_ops']);
111+
await e.insert('sys_user_permission_set', {
112+
id: 'ups_g_ops', user_id: USER_G, permission_set_id: 'ps_global_platform_ops', organization_id: null,
113+
}, SYS);
114+
return engine;
115+
}
116+
117+
function observed(engine: any, reads: any[]): any {
118+
return {
119+
registry: engine.registry,
120+
registerMiddleware: (...a: any[]) => engine.registerMiddleware?.(...a),
121+
getSchema: (n: string) => engine.getSchema?.(n),
122+
find: async (o: string, q?: any, opt?: any) => {
123+
const r = await engine.find(o, q, opt);
124+
if (o === 'sys_permission_set' && q?.where?.name?.$in) {
125+
reads.push({
126+
where: q.where,
127+
context: opt?.context ?? q?.context,
128+
rows: (r as any[]).map((x) => ({ id: x.id, name: x.name, organization_id: x.organization_id ?? null })),
129+
});
130+
}
131+
return r;
132+
},
133+
findOne: (o: string, q?: any, opt?: any) => {
134+
assertEngineFindOnePredicate(o, q);
135+
return engine.findOne(o, q, opt);
136+
},
137+
insert: (o: string, d: any, opt?: any) => engine.insert(o, d, opt),
138+
update: (o: string, d: any, opt?: any) => {
139+
assertEngineUpdateDispatch(d, opt);
140+
return engine.update(o, d, opt);
141+
},
142+
delete: (o: string, id: any, opt?: any) => {
143+
assertEngineDeleteDispatch(opt);
144+
return engine.delete(o, id, opt);
145+
},
146+
};
147+
}
148+
149+
async function securityServiceOver(engine: any, reads: any[]) {
150+
const plugin = new SecurityPlugin();
151+
const svc = observed(engine, reads);
152+
const services: Record<string, any> = {};
153+
const ctx: any = {
154+
logger: { info: () => {}, warn: () => {}, debug: () => {}, error: () => {} },
155+
registerService: (n: string, s: any) => { services[n] = s; },
156+
registerMiddleware: () => {},
157+
getService: (n: string) => {
158+
if (n === 'objectql') return svc;
159+
if (n === 'metadata') return { list: async () => [] };
160+
if (n === 'manifest') return { register: () => {} };
161+
return services[n];
162+
},
163+
};
164+
await plugin.init(ctx);
165+
await plugin.start(ctx);
166+
expect(services.security, 'security service never registered').toBeTruthy();
167+
return services.security;
168+
}
169+
170+
async function reading(security: any, reads: any[], base: any, organizationId: string | undefined) {
171+
const ctx = organizationId ? { ...base, tenantId: organizationId, organizationId } : { ...base, tenantId: undefined };
172+
reads.length = 0;
173+
const sets = await security.resolvePermissionSetsForContext(ctx);
174+
const loaderReads = [...reads];
175+
const eff = await security.getEffectiveObjectPermissions({ ...ctx });
176+
return {
177+
positions: base.positions,
178+
permissions: base.permissions,
179+
coreSystemPermissions: base.systemPermissions,
180+
posture: base.posture,
181+
loaderReads,
182+
sets: sets.map((s: any) => ({ name: s.name, label: s.label, systemPermissions: s.systemPermissions })),
183+
effectiveProbeLedger: eff?.probe_ledger ?? null,
184+
};
185+
}
186+
187+
describe('[#20555] PROBE', () => {
188+
it('records the org-less resolution', async () => {
189+
const engine = await boot();
190+
const reads: any[] = [];
191+
const security = await securityServiceOver(engine, reads);
192+
193+
const now = Date.now();
194+
const out = {
195+
userB_orgless: await reading(security, reads, await buildContextForUser(engine, USER_B, now, undefined), undefined),
196+
userB_home: await reading(security, reads, await buildContextForUser(engine, USER_B, now, ORG_B), ORG_B),
197+
userG_orgless: await reading(security, reads, await buildContextForUser(engine, USER_G, now, undefined), undefined),
198+
userG_home: await reading(security, reads, await buildContextForUser(engine, USER_G, now, ORG_B), ORG_B),
199+
};
200+
const file = process.env.OS_TEST_PROBE_20555_OUT;
201+
if (file) writeFileSync(file, JSON.stringify(out, null, 2));
202+
}, 120_000);
203+
});

0 commit comments

Comments
 (0)