Summary
An unresolvable {{ jwt.* }} template in a policy row-filter renders as the empty string and emits a real predicate against '', instead of denying. Only _in was given the fail-closed treatment (#224); _eq/_neq/_gt/_lt fail open.
Detail
internal/policy/policy.go:
resolveTemplate (:266-279) returns "" when a claim path can't be resolved.
resolveFilters (:226-244) binds that "" directly for _eq/_neq/_gt/_lt.
_in alone emits 1 = 0 for an empty/unresolvable set (:248-253).
Example policy: select: { user: { filter: { tenant_id: { _eq: "{{ jwt.tenant_id }}" } } } }. A validly-signed token with role: user but no tenant_id claim (mixed IdP audiences, service tokens) yields WHERE tenant_id = '' → leaks every row whose tenant_id is empty. Worse, _neq/_gt on a string column (col != '' / col > '') matches essentially all rows — the restriction evaporates entirely.
The docs promise the opposite: docs/src/content/docs/access-control.mdx:233 says an unresolvable claim "matches no tenant and sees nothing" — only true for _in today.
Fix direction
When a filter template resolves to empty (claim absent), fail closed (1 = 0) rather than binding '', matching the _in behavior and the documented contract.
Found in a repo-wide audit; verified by code trace. Sibling of closed #224 (which fixed only _in) and #371 (check-path operators, not the filter path).
Summary
An unresolvable
{{ jwt.* }}template in a policy row-filter renders as the empty string and emits a real predicate against'', instead of denying. Only_inwas given the fail-closed treatment (#224);_eq/_neq/_gt/_ltfail open.Detail
internal/policy/policy.go:resolveTemplate(:266-279) returns""when a claim path can't be resolved.resolveFilters(:226-244) binds that""directly for_eq/_neq/_gt/_lt._inalone emits1 = 0for an empty/unresolvable set (:248-253).Example policy:
select: { user: { filter: { tenant_id: { _eq: "{{ jwt.tenant_id }}" } } } }. A validly-signed token withrole: userbut notenant_idclaim (mixed IdP audiences, service tokens) yieldsWHERE tenant_id = ''→ leaks every row whosetenant_idis empty. Worse,_neq/_gton a string column (col != ''/col > '') matches essentially all rows — the restriction evaporates entirely.The docs promise the opposite:
docs/src/content/docs/access-control.mdx:233says an unresolvable claim "matches no tenant and sees nothing" — only true for_intoday.Fix direction
When a filter template resolves to empty (claim absent), fail closed (
1 = 0) rather than binding'', matching the_inbehavior and the documented contract.Found in a repo-wide audit; verified by code trace. Sibling of closed #224 (which fixed only
_in) and #371 (check-path operators, not the filter path).