Skip to content

Commit 7d52fea

Browse files
authored
Merge pull request #1657 from objectstack-ai/feat/security-getreadfilter-analytics-rls
feat(security): expose getReadFilter; analytics enforces real RLS (ADR-0021 D-C) [security review]
2 parents 7ef0235 + 69da71b commit 7d52fea

5 files changed

Lines changed: 428 additions & 92 deletions

File tree

packages/plugins/plugin-security/src/security-plugin.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,102 @@ describe('SecurityPlugin', () => {
287287
expect(opCtx.ast.where).toEqual({ organization_id: 'org-1' });
288288
});
289289

290+
// -------------------------------------------------------------------------
291+
// getReadFilter service (ADR-0021 D-C) — the reusable READ scope the
292+
// analytics raw-SQL path bridges to. Must produce the SAME FilterCondition
293+
// the engine middleware injects on `find`, and fail CLOSED on any error.
294+
// -------------------------------------------------------------------------
295+
describe('getReadFilter service', () => {
296+
it('registers a "security" service exposing getReadFilter', async () => {
297+
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
298+
const harness = makeMiddlewareCtx({ permissionSets: [tenantPolicySet] });
299+
await plugin.init(harness.ctx);
300+
await plugin.start(harness.ctx);
301+
const call = (harness.ctx.registerService as any).mock.calls.find(
302+
(c: any[]) => c[0] === 'security',
303+
);
304+
expect(call).toBeTruthy();
305+
expect(typeof call[1].getReadFilter).toBe('function');
306+
});
307+
308+
it('returns the SAME tenant filter the find-path injects (org-scoping on)', async () => {
309+
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
310+
const harness = makeMiddlewareCtx({ permissionSets: [tenantPolicySet], orgScoping: true });
311+
await plugin.init(harness.ctx);
312+
await plugin.start(harness.ctx);
313+
const ctx = { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] };
314+
const filter = await plugin.getReadFilter('task', ctx);
315+
expect(filter).toEqual({ organization_id: 'org-1' });
316+
});
317+
318+
it('returns undefined (no scope) when tenant_isolation is stripped (org-scoping off)', async () => {
319+
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
320+
const harness = makeMiddlewareCtx({ permissionSets: [tenantPolicySet] });
321+
await plugin.init(harness.ctx);
322+
await plugin.start(harness.ctx);
323+
const filter = await plugin.getReadFilter('task', { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] });
324+
expect(filter).toBeUndefined();
325+
});
326+
327+
it('fail-closed: wildcard org policy on an object missing the column → deny sentinel', async () => {
328+
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
329+
const harness = makeMiddlewareCtx({
330+
permissionSets: [tenantPolicySet],
331+
objectFields: ['id', 'name'], // no organization_id, tenancy not opted out
332+
orgScoping: true,
333+
});
334+
await plugin.init(harness.ctx);
335+
await plugin.start(harness.ctx);
336+
const filter = await plugin.getReadFilter('task', { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] });
337+
expect(filter).toEqual(RLS_DENY_FILTER);
338+
});
339+
340+
it('tenancy opt-out → undefined (not denied), matching the find-path', async () => {
341+
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
342+
const harness = makeMiddlewareCtx({
343+
permissionSets: [tenantPolicySet],
344+
objectFields: ['id', 'name'],
345+
schemaExtra: { tenancy: { enabled: false, strategy: 'shared' } },
346+
orgScoping: true,
347+
});
348+
await plugin.init(harness.ctx);
349+
await plugin.start(harness.ctx);
350+
const filter = await plugin.getReadFilter('task', { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] });
351+
expect(filter).toBeUndefined();
352+
});
353+
354+
it('system context bypasses scoping (returns undefined)', async () => {
355+
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
356+
const harness = makeMiddlewareCtx({ permissionSets: [tenantPolicySet], orgScoping: true });
357+
await plugin.init(harness.ctx);
358+
await plugin.start(harness.ctx);
359+
const filter = await plugin.getReadFilter('task', { isSystem: true, userId: 'u1', tenantId: 'org-1' });
360+
expect(filter).toBeUndefined();
361+
});
362+
363+
it('anonymous (no userId/roles/permissions) → undefined (authn gated elsewhere)', async () => {
364+
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
365+
const harness = makeMiddlewareCtx({ permissionSets: [tenantPolicySet], orgScoping: true });
366+
await plugin.init(harness.ctx);
367+
await plugin.start(harness.ctx);
368+
const filter = await plugin.getReadFilter('task', { roles: [], permissions: [] });
369+
expect(filter).toBeUndefined();
370+
});
371+
372+
it('fail-closed: a permission-resolution throw yields the deny sentinel (never allow-all)', async () => {
373+
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
374+
const harness = makeMiddlewareCtx({ permissionSets: [tenantPolicySet], orgScoping: true });
375+
await plugin.init(harness.ctx);
376+
await plugin.start(harness.ctx);
377+
// Force resolution to blow up.
378+
(plugin as any).permissionEvaluator.resolvePermissionSets = async () => {
379+
throw new Error('metadata service unavailable');
380+
};
381+
const filter = await plugin.getReadFilter('task', { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] });
382+
expect(filter).toEqual(RLS_DENY_FILTER);
383+
});
384+
});
385+
290386
// -------------------------------------------------------------------------
291387
// FLS write enforcement (Backend FLS strip — gap #1)
292388
// -------------------------------------------------------------------------

0 commit comments

Comments
 (0)