Repository navigation
security(policy): mistyped or unhonored policy rules load silently and disable row security #460
Description
Activity
- addedbugSomething isn't workingSomething isn't workingarea/policyAccess control policies (Hasura-style)Access control policies (Hasura-style)securitySecurity-sensitive issue or fixSecurity-sensitive issue or fixbreaking-changeBreaking change to public API, CLI, or configBreaking change to public API, CLI, or configarea/configConfig file, config knobs, hot-reloadConfig file, config knobs, hot-reload
on Aug 12, 2026 coderabbitai commented
on Aug 12, 2026 coderabbitaiboton Aug 12, 2026 – with coderabbitaiMore actions🔗 Related PRs
#172 - feat(rbac)!: fail-closed authorization + default_role public access [closed]
#358 - fix(policy): enforce _in on filter and check paths [merged]
#378 - feat(auth): non-JWT operator key for admin + break-glass access [merged]
#381 - fix(stream): apply policy row-filter per subscriber on SSE [open]
#457 - fix(policy): fail closed on unresolvable row-filter claims [open]
🧪 Issue enrichment is currently in open beta.
You can configure auto-planning by selecting labels in the issue_enrichment configuration.
To disable automatic issue enrichment, add the following to your
.coderabbit.yaml:issue_enrichment: auto_enrich: enabled: false
💬 Have feedback or questions? Drop into our discord!
Dependency note: #461 blocks the effectiveness of everything proposed here.
validateRolePermsonly runs viaStore.Putand the bootstrap-file path — a policy already in KV is cached byStore.loadwithout validation (internal/policy/store.go:184-188), andNewStoretakes that branch first whenever KV is populated.So each rule added here will reject new policies and fresh deployments, while every already-running deployment keeps its existing policy unvalidated. #457 just demonstrated this with its malformed-template rejection. Worth sequencing #461 first, or at least landing them together, so these rules actually reach production rather than only new installs.
Scoped down, and raised to P0.
The strict-decoding half of this issue's Scope section is now #514 (P2). Reason: it's a breaking change to config parsing needing warn-then-reject over a release — which this issue's own "Migration risk" section says — and leaving it here forced the urgent half to move at its pace. The two are separable: rejecting an all-nil
Filterdoesn't need strict decoding to work.This issue now covers the detection half: reject an all-nil
Filter(and all-nilcheck) invalidateRolePerms, rejectfilter:under aninsert:role entry, and makePOST /v1/ops/policy/validatestop returning{"valid":true}for a policy whose row security is silently off. That last part is what makes it P0 — the safety net currently certifies the breach.Priority raised P1 → P0 under the post-launch anchor ("live in a shipped public release and severe enough to cut a patch for"); full rationale and the re-anchor itself are in #501.
Ordering: #461 should land first —
validateRolePermsruns only viaStore.Put, so every rule added here is inert on deployments already loading their policy from KV.Body left unedited; the Scope section still lists the strict-decoding bullet, now tracked in #514.
pm-triage routine, 2026-08-25, on Eric's instruction.
- moved this from Backlog to In progress in WaveHouse Task Board
on Sep 1, 2026
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsDone
Area: policy — security (fail-open) · found via PR #457 review
Expected: a policy rule that the engine will not honor — because it was mistyped, or because it has no meaning for that operation — is rejected at config load, loudly. Row security is never silently disabled.
Actual: two independent gaps let a malformed rule load cleanly and produce no predicate at all, which on the row-
filterpath means the role reads the whole table.1. A typo'd operator key silently disables row security
Every decoder is non-strict —
internal/policy/store.go:153,185,210,214andinternal/api/policy.go:46,71— so an unknown key is dropped rather than rejected. AFilterwith all five operator pointers nil then contributes zero clauses (resolveFiltershas oneifper operator and noelse),EvaluateleavesWhereClauseempty (internal/policy/policy.go:192-196), andInjectPermissionFiltersno-ops on an empty clause.validateRolePerms(internal/policy/policy.go:517-523) checks onlychsql.BindUnsafe(col)for filter columns — it never requires that an operator be set.Verified end-to-end:
A one-character typo disables RLS, and the validate endpoint actively confirms the policy is fine.
Note the asymmetry: Go's
encoding/jsonmatches field names case-insensitively, so_EQand_Eqdo work — buteqdoes not, and YAML is exact-match only. The format is lenient in a direction nobody needs and silent in the one that bites.2.
filter:under aninsert:role entry is resolved, then ignoredinternal/policy/policy.go:186-197resolves filters for every operation, butinternal/api/ingest.goreads onlyCheckClausesand the column rules. Nothing rejects the policy — in pointed contrast to the loud config-load rejection of_neq/_gt/_ltoncheckatinternal/policy/policy.go:531. Same accept-but-ignore family as #224.Scope
Filter(and an all-nilcheckentry) invalidateRolePerms.filter:under aninsert:role entry, matching the existingcheckoperator rejection.yaml.Decoder.KnownFields(true)andDisallowUnknownFields— at the six decode sites above.Migration risk (why this is not a drive-by)
Strict decoding is a breaking change to config parsing: any stored policy or bootstrap file with a stray or misspelled key currently loads fine and would start hard-failing, and
Validatealso runs on KV-loaded policies — so this can take a deployment down at boot. Consider warn-then-reject over one release, and decide explicitly whether an already-stored bad policy should fail boot or load with a loud warning.Related
validateRolePermsfor the same reason (an unhonored rule must not load silently) — that is the first instance of this pattern; these are the rest.policy validateCLI) would catch case 1 at authoring time, in-editor, before it ever reaches the server. Natural companion._inaccepted but never enforced;checkhonors only_eq#224 — the original accept-but-ignore fail-open this shares a family with.Found during the PR #457 review (fail-closed row-filter claims); verified by execution, not code-trace alone.
Update (2026-09-01)
Resolved across #508 and PR #541 — item by item against the Scope list:
Filter(and an all-nilcheckentry): landed in PR fix(policy): reject no-op rules, drop the policy HTTP surface #541 (validateRolePermsrejects an operator-lessfilterorcheckentry). The explicit spelling"tenant_id": {}— which strict decoding cannot catch, being syntactically innocent — no longer adopts.filter:under aninsert:role entry: landed in PR fix(policy): reject no-op rules, drop the policy HTTP surface #541, matching the existing loudcheck_neq/_gt/_ltrejection.POST /v1/ops/policy/validate— the last lenient decoder — outright (the PR drops the policy's whole HTTP surface,GET /v1/ops/policyincluded).Because #508 funnels every adoption (boot, watch, SIGHUP, reload,
wavehouse validate) through the onepolicy.Validatepath, the new rejections apply on every surface at once.Note: #508's "Closes" list auto-closed only #229 (GitHub binds one issue per keyword) — this issue stayed open by accident, correctly so given the items above were still outstanding.