@@ -9,6 +9,32 @@ import type { ExecutionContext } from '@objectstack/spec/kernel';
99// now a consumer of that one definition, exactly as the lint gate is; there is
1010// no second copy for the `=` / `IN` bridge to drift against.
1111import { compileCelToFilter , isSupportedRlsExpression , sqlPredicateToCel } from '@objectstack/formula' ;
12+ import type { CelFilterFailReason } from '@objectstack/formula' ;
13+
14+ /**
15+ * Why a policy's predicate produced no filter — the compiler's OWN answer,
16+ * carried instead of discarded.
17+ *
18+ * `compileCelToFilter` already returns `{ reason, detail }` on every refusal;
19+ * until #13639 `compileExpression` consumed `!ok` and threw the rest away one
20+ * line before the only place that could surface it. The extra member is this
21+ * file's own drop, which the compiler reports as a SUCCESS (`ok: true`) and
22+ * {@link isEmptyMembershipFilter} then refuses — same silent denial, so it
23+ * joins the same vocabulary rather than staying unnamed.
24+ */
25+ type RlsDropReason = CelFilterFailReason | 'empty-membership' ;
26+
27+ /** A dropped policy's cause: the compiler's reason plus its human `detail`. */
28+ interface RlsDropCause {
29+ reason : RlsDropReason ;
30+ /** The compiler's `detail` — names the variable path, the member index, the bound. */
31+ detail : string ;
32+ }
33+
34+ /** {@link RLSCompiler.compileExpressionOutcome }'s answer: the filter, or why there is none. */
35+ type RlsCompileOutcome =
36+ | { filter : Record < string , unknown > ; cause ?: undefined }
37+ | { filter : null ; cause : RlsDropCause } ;
1238
1339/**
1440 * RLS User Context
@@ -207,6 +233,22 @@ export class RLSCompiler {
207233 this . logger = logger ;
208234 }
209235
236+ /**
237+ * Causes already WARNed about by {@link warnFailClosedDenial}, so a policy
238+ * that denies on every read warns ONCE per distinct cause rather than once
239+ * per request. This seam runs on the read path: the "no active organization"
240+ * denial is a persistent SESSION state, not a one-off, so an un-memoised line
241+ * would be a line per query for as long as the state lasts.
242+ *
243+ * Not a new mechanism — it is `cel-to-filter.ts`'s `warnedOverLimit` memo, the
244+ * immediately-upstream module in this same call chain, at the same bound and
245+ * with the same clear-on-overflow: an unbounded set keyed by author-controlled
246+ * strings is a leak. Per INSTANCE (the plugin holds one long-lived compiler),
247+ * so a test's fresh `new RLSCompiler()` starts from an empty memo.
248+ */
249+ private warnedDenials = new Set < string > ( ) ;
250+ private static readonly WARNED_DENIALS_MAX = 500 ;
251+
210252 /**
211253 * Compile RLS policies into a query filter for the given user context.
212254 * Multiple policies for the same object/operation are OR-combined (any match allows access).
@@ -252,6 +294,11 @@ export class RLSCompiler {
252294 }
253295
254296 const filters : Record < string , unknown > [ ] = [ ] ;
297+ /**
298+ * [#13639] Policies whose SHAPE was fine but whose evaluation refused — the
299+ * class that produced the reported failure mode: zero rows, no error, no log.
300+ */
301+ const deniedBy : { policy : RowLevelSecurityPolicy ; cause : RlsDropCause } [ ] = [ ] ;
255302 let applicable = 0 ;
256303
257304 for ( const policy of policies ) {
@@ -265,19 +312,29 @@ export class RLSCompiler {
265312 // WITHOUT counting it toward the fail-closed deny below.
266313 if ( ! predicate ) continue ;
267314 applicable ++ ;
268- const filter = this . compileExpression ( predicate , userCtx ) ;
269- if ( filter ) {
270- filters . push ( filter ) ;
315+ const outcome = this . compileExpressionOutcome ( predicate , userCtx ) ;
316+ if ( outcome . filter ) {
317+ filters . push ( outcome . filter ) ;
271318 } else if ( ! isSupportedRlsExpression ( predicate ) ) {
272319 // ADR-0056 D4: an UNSUPPORTED-SHAPE predicate (e.g. arithmetic, functions,
273320 // subqueries) compiles to nothing and would silently vanish, leaving the
274- // object unprotected. Surface it instead of dropping in silence. (A
275- // SUPPORTED shape that returned null is the intentional "context var
276- // absent" path — it fails closed downstream and is not warned here.)
321+ // object unprotected. Surface it instead of dropping in silence. This
322+ // branch is an AUTHORING fault — the predicate can never enforce, for any
323+ // input — so it warns on every drop, denial or not, and it now carries the
324+ // compiler's `detail` (WHICH shape was refused) rather than only the fact.
277325 this . logger ?. warn ?.(
278326 `[RLS] policy '${ ( policy as { name ?: string } ) . name ?? '(unnamed)' } ' on '${ ( policy as { object ?: string } ) . object ?? '?' } ' ` +
279- `has an uncompilable predicate (${ clause } clause) and was DROPPED (no enforcement): ${ predicate } ` ,
327+ `has an uncompilable predicate (${ clause } clause) and was DROPPED (no enforcement): ${ predicate } ` +
328+ ` — ${ outcome . cause . detail } ` ,
280329 ) ;
330+ } else {
331+ // [#13639] The previously SILENT branch. The shape is fine; the REQUEST
332+ // could not be evaluated — an unresolved `current_user.*` variable, an
333+ // unresolved MEMBER of a membership array, or an emptied membership set.
334+ // Collected, not warned yet: on its own a dropped policy is not a denial
335+ // (a sibling policy may still grant, and the caller sees rows), so the
336+ // line is emitted below only if this clause actually fails closed.
337+ deniedBy . push ( { policy, cause : outcome . cause } ) ;
281338 }
282339 }
283340
@@ -291,6 +348,13 @@ export class RLSCompiler {
291348 // expression we couldn't compile). Fail closed — return a sentinel
292349 // filter that matches no rows. This prevents the "user without an
293350 // active org sees every tenant's data" class of bug.
351+ //
352+ // [#13639] And SAY SO. This is the fail-closed path working as designed,
353+ // which is exactly why it needs a trace: the caller gets zero rows, no
354+ // error is raised, and a correct refusal is indistinguishable from "the
355+ // data genuinely doesn't match" — a search that costs hours and that the
356+ // compiler's own `detail` ends in one line.
357+ for ( const { policy, cause } of deniedBy ) this . warnFailClosedDenial ( policy , clause , cause ) ;
294358 return RLS_DENY_FILTER ;
295359 }
296360 if ( filters . length === 1 ) return filters [ 0 ] ;
@@ -299,6 +363,47 @@ export class RLSCompiler {
299363 return { $or : filters } ;
300364 }
301365
366+ /**
367+ * [#13639] The line an operator needs when a read returns nothing.
368+ *
369+ * The failure shape this exists for is the worst-shaped one available: the
370+ * user sees zero rows, no error is raised, and every other signal points away
371+ * from the cause. The information that ends the search — WHICH variable did
372+ * not resolve, and at which member index — was computed by the compiler and
373+ * then discarded one line before it could be used. This carries it.
374+ *
375+ * Emitted only when the clause actually DENIES (see {@link compileFilter}) and
376+ * only once per distinct cause, because this seam runs on read paths.
377+ */
378+ private warnFailClosedDenial (
379+ policy : RowLevelSecurityPolicy ,
380+ clause : 'using' | 'check' ,
381+ cause : RlsDropCause ,
382+ ) : void {
383+ const name = ( policy as { name ?: string } ) . name ?? '(unnamed)' ;
384+ const object = ( policy as { object ?: string } ) . object ?? '?' ;
385+ const predicate = ( policy as { using ?: string ; check ?: string } ) [ clause ] ?? policy . using ?? '' ;
386+ const key = `${ object } |${ clause } |${ name } |${ cause . reason } |${ cause . detail } ` ;
387+ if ( this . warnedDenials . has ( key ) ) return ;
388+ if ( this . warnedDenials . size >= RLSCompiler . WARNED_DENIALS_MAX ) this . warnedDenials . clear ( ) ;
389+ this . warnedDenials . add ( key ) ;
390+ this . logger ?. warn ?.(
391+ `[RLS] DENY (fail closed): policy '${ name } ' on '${ object } ' could not be evaluated for this request ` +
392+ `(${ clause } clause, ${ cause . reason } ): ${ cause . detail } . Every applicable policy dropped, so the request ` +
393+ `is filtered by RLS_DENY_FILTER ('${ String ( RLS_DENY_FILTER . id ) . split ( ':' ) [ 0 ] } ') and returns ZERO ROWS — ` +
394+ `a deliberate REFUSAL, not an empty result set. Predicate: ${ predicate } ` ,
395+ {
396+ object,
397+ policy : name ,
398+ clause,
399+ reason : cause . reason ,
400+ detail : cause . detail ,
401+ predicate,
402+ filter : RLS_DENY_FILTER . id ,
403+ } ,
404+ ) ;
405+ }
406+
302407 /**
303408 * Compile a single RLS predicate into a query filter (ADR-0058 D1/D2).
304409 *
@@ -323,7 +428,22 @@ export class RLSCompiler {
323428 expression : string ,
324429 userCtx : RLSUserContext
325430 ) : Record < string , unknown > | null {
326- if ( ! expression ) return null ;
431+ return this . compileExpressionOutcome ( expression , userCtx ) . filter ;
432+ }
433+
434+ /**
435+ * [#13639] {@link compileExpression}'s answer WITH the reason it refused.
436+ *
437+ * Same compile, same decision, same returned filter — the only difference is
438+ * that the compiler's `{ reason, detail }` survives to the caller instead of
439+ * being collapsed into `null` at the `!result.ok` line. `compileExpression`
440+ * stays exactly as published (`Record | null`) and delegates here.
441+ */
442+ private compileExpressionOutcome (
443+ expression : string ,
444+ userCtx : RLSUserContext
445+ ) : RlsCompileOutcome {
446+ if ( ! expression ) return { filter : null , cause : { reason : 'parse-error' , detail : 'empty expression' } } ;
327447 // [ADR-0058 D1] CEL is canonical. The legacy SQL-ish form still compiles via
328448 // the transitional bridge, but we surface it so authored policies migrate to
329449 // CEL — the bridge will be removed once no stored predicate needs it.
@@ -340,16 +460,33 @@ export class RLSCompiler {
340460 // Any fault — unsupported shape, parse error, or an unresolved/null
341461 // `current_user.*` variable — drops the policy. With a single applicable
342462 // policy this surfaces as RLS_DENY_FILTER upstream (fail closed).
343- if ( ! result . ok ) return null ;
463+ // [#13639] The refusal keeps its REASON. `reason` + `detail` are what the
464+ // compiler already computed — the variable path, the member index, the bound
465+ // that was overrun — and discarding them here is what left an operator with
466+ // zero rows and no signal at all.
467+ if ( ! result . ok ) return { filter : null , cause : { reason : result . reason , detail : result . detail } } ;
344468 // Parity: an empty pre-resolved membership (`field in current_user.<empty>`)
345469 // compiles to `{ field: { $in: [] } }`. The legacy compiler dropped the
346470 // policy in this case; preserve that so the deny sentinel (not a literal
347471 // empty-IN) is what the single-policy path returns. [#13552] The guard is
348472 // polarity-aware: the same emptied set under a supported `not in`
349473 // (`$not` wrapping, at any composition depth) is dropped too — otherwise
350474 // it inverts to a constant-TRUE clause and the policy compiles ALLOW-ALL.
351- if ( isEmptyMembershipFilter ( result . filter as Record < string , unknown > ) ) return null ;
352- return result . filter as Record < string , unknown > ;
475+ if ( isEmptyMembershipFilter ( result . filter as Record < string , unknown > ) ) {
476+ // [#13639] The compiler answered `ok`, so there is no `detail` to carry —
477+ // this drop is THIS file's, and it is just as silent and just as
478+ // fail-closed. It names itself rather than being reported as a success.
479+ return {
480+ filter : null ,
481+ cause : {
482+ reason : 'empty-membership' ,
483+ detail :
484+ 'a pre-resolved membership set is EMPTY, so the policy is degenerate ' +
485+ `(compiled to ${ JSON . stringify ( result . filter ) } ) and was dropped rather than enforced` ,
486+ } ,
487+ } ;
488+ }
489+ return { filter : result . filter as Record < string , unknown > } ;
353490 }
354491
355492 /**
0 commit comments