Skip to content

[finding] an RLS denial caused by an unresolved variable logs NOTHING and explain reports it as narrows rather than denies — the operator debugging "this user sees no rows" gets no signal #13639

Description

@zhuangjianguo

Filed by the domain:engine lane PM, from a measurement made during the clause ② contract review of PR #13630 (#13496). Recording only — no severity asserted, routing is triage's.

⚠️ PRE-EXISTING and low-urgency. This is the observability of the long-standing scalar "no active org" fail-closed path. PR #13630 neither introduced nor degraded it — that PR was measured to inherit this behaviour, which is why it was accepted rather than held. Ranking it is triage's; I am filing it because it now has a precise measurement and would otherwise be lost with the review thread.

Measured, at 1cdf3c14ef

When compileCelToFilter refuses (a current_user.* variable resolving to null/undefined, and now also a null MEMBER of a membership array), the compiler produces a useful detail"unresolved member at index N", naming the variable path. Three things then happen to it:

stage what happens
plugin-security/rls-compiler.ts:343 the detail is discarded — only !ok is consumed
the drop-warn beside it gated on !isSupportedRlsExpression, which is true for membership shapesnothing logs
explain reports the sentinel with verdict narrows, not denies — its isDenyAll matches only its own __deny_all__, never __rls_deny__

So the denial is real and correct — record attribution excludes properly, and __rls_deny__ is visible in readFilter — but the reason is thrown away at the one place that could surface it, and the one diagnostic tool that exists mislabels the outcome.

Why it matters more than an ordinary missing log

The failure mode this produces is the worst-shaped one for an operator: a user sees zero rows, no error is raised, nothing appears in the log, and the built-in explanation tool says the policy "narrows" rather than "denies". Every signal available points away from the actual cause.

⭐ And the denial is deliberate — it is the fail-closed path working as designed. That is exactly why it needs a trace: a correct refusal that is indistinguishable from "the data genuinely doesn't match" costs an operator hours, and the information needed to end that search was computed and then dropped one line before it could be used.

Scope NOT established

  • ⛔ Whether other !ok consumers discard the same detail is unmeasured — I checked this one path.
  • ⛔ Whether explain's isDenyAll should recognise __rls_deny__, or whether the two sentinels are deliberately distinct, is a design question I am not answering.
  • ⛔ No claim that an operator has been bitten. What is measured is that the signal does not exist.

Suggested shape, not a prescription

Two candidates, both small, neither costed here: surface the discarded detail at the drop site (the warn is already there, only mis-gated), and/or teach explain's isDenyAll about __rls_deny__. Which, and whether either is worth doing now, is triage's and the maintainer's.

Related

#13496 / PR #13630 (where the clause ② review measured it) · #13552 (the polarity-blind deny guard on the same seam) · ADR-0056 / ADR-0058

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions