@@ -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