Skip to content

Commit 192b2ba

Browse files
claude[bot]claude
andauthored
fix(plugin-security): an RLS fail-closed denial logs its reason, not nothing (#13942)
`compileCelToFilter` computes a precise `detail` when it refuses — the `current_user.*` path that did not resolve, the index of the null member of a membership array — and `RLSCompiler.compileExpression` discarded it at `if (!result.ok) return null`, one line before the only site that could surface it. The warn beside that drop was gated on `!isSupportedRlsExpression`, a SHAPE-only test that answers "supported" for precisely those shapes, so the membership and no-active-organization refusals logged nothing at all. Carry the reason instead: `compileExpressionOutcome` returns the filter or the cause, `compileExpression` keeps its published `Record | null` signature, and the drop site emits one line per distinct cause when — and only when — the clause actually fails closed. The emptied-membership drop, which the compiler reports as a success and this file then refuses, joins the same vocabulary. Nothing about the decision moves: `RLS_DENY_FILTER` still lands, record attribution still excludes, zero rows still means zero rows. Part of #13639 Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2aef62e commit 192b2ba

4 files changed

Lines changed: 482 additions & 16 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
**An RLS denial caused by an unresolved variable now leaves a trace — and the trace carries the reason.**
6+
7+
When `compileCelToFilter` refuses a policy predicate, it produces a precise `detail`: which
8+
`current_user.*` variable did not resolve, or which member of a pre-resolved membership array
9+
came back `null`, and at what index. `RLSCompiler.compileExpression` consumed only `!ok` and
10+
threw that `detail` away one line before the only place that could surface it, and the warn
11+
sitting beside the drop was gated on `isSupportedRlsExpression` — a SHAPE-only test that
12+
answers "supported" for exactly these shapes, so nothing logged.
13+
14+
The result was the worst-shaped failure an operator can be handed: the caller sees zero rows,
15+
no error is raised, nothing appears in the log — and the denial is *deliberate*, the
16+
fail-closed path working as designed, so a correct refusal is indistinguishable from "the data
17+
genuinely doesn't match".
18+
19+
The drop site now keeps the compiler's reason and, when every applicable policy has dropped and
20+
the clause actually fails closed, logs one line naming the policy, the object, the clause, the
21+
predicate, the variable path, the member index and the consequence (`__rls_deny__`, zero rows,
22+
a refusal rather than an empty result set). The same line covers the emptied-membership drop,
23+
which the compiler reports as a success and this file then refuses — silent for the same reason.
24+
25+
Nothing about the decision moves. `RLS_DENY_FILTER` still lands in the read filter, record
26+
attribution still excludes, zero rows still means zero rows, and `compileExpression` keeps its
27+
published `Record | null` signature. A predicate that never compiles for any input keeps its
28+
existing "DROPPED (no enforcement)" line (now also carrying the compiler's reason) rather than
29+
gaining a second one; a dropped policy whose sibling still grants stays silent, because that
30+
caller sees rows; and because this seam runs on read paths the denial line is emitted once per
31+
distinct cause rather than once per request.

packages/plugins/plugin-security/src/rls-compiler.ts

Lines changed: 148 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
1111
import { 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

Comments
 (0)