Skip to content

security(policy): mistyped or unhonored policy rules load silently and disable row security #460

Description

@EricAndrechek

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-filter path 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,214 and internal/api/policy.go:46,71 — so an unknown key is dropped rather than rejected. A Filter with all five operator pointers nil then contributes zero clauses (resolveFilters has one if per operator and no else), Evaluate leaves WhereClause empty (internal/policy/policy.go:192-196), and InjectPermissionFilters no-ops on an empty clause.

validateRolePerms (internal/policy/policy.go:517-523) checks only chsql.BindUnsafe(col) for filter columns — it never requires that an operator be set.

Verified end-to-end:

select:
  user:
    filter: { tenant_id: { eq: "{{ jwt.tenant_id }}" } }   # "eq", not "_eq"
→ Filter{Eq:nil, Neq:nil, Gt:nil, Lt:nil, In:nil}
→ Allowed=true, WhereClause=""
→ SELECT * FROM `clicks` LIMIT 10000        # entire table, every tenant
→ Validate() == nil, and POST /v1/admin/policy/validate returns {"valid":true}

A one-character typo disables RLS, and the validate endpoint actively confirms the policy is fine.

Note the asymmetry: Go's encoding/json matches field names case-insensitively, so _EQ and _Eq do work — but eq does 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 an insert: role entry is resolved, then ignored

internal/policy/policy.go:186-197 resolves filters for every operation, but internal/api/ingest.go reads only CheckClauses and the column rules. Nothing rejects the policy — in pointed contrast to the loud config-load rejection of _neq/_gt/_lt on check at internal/policy/policy.go:531. Same accept-but-ignore family as #224.

Scope

  • Reject an all-nil Filter (and an all-nil check entry) in validateRolePerms.
  • Reject filter: under an insert: role entry, matching the existing check operator rejection.
  • Turn on strict decoding — yaml.Decoder.KnownFields(true) and DisallowUnknownFields — 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 Validate also 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


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:

Because #508 funnels every adoption (boot, watch, SIGHUP, reload, wavehouse validate) through the one policy.Validate path, 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.

Activity

  1. added
    bugSomething isn't working
    area/policyAccess control policies (Hasura-style)
    securitySecurity-sensitive issue or fix
    breaking-changeBreaking change to public API, CLI, or config
    area/configConfig file, config knobs, hot-reload
    on Aug 12, 2026
  2. coderabbitai commented on Aug 12, 2026

    @coderabbitai
    🔗 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!

  3. EricAndrechek commented on Aug 12, 2026

    @EricAndrechek
    MemberAuthor

    Dependency note: #461 blocks the effectiveness of everything proposed here. validateRolePerms only runs via Store.Put and the bootstrap-file path — a policy already in KV is cached by Store.load without validation (internal/policy/store.go:184-188), and NewStore takes 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.

  4. EricAndrechek commented on Aug 25, 2026

    @EricAndrechek
    MemberAuthor

    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 Filter doesn't need strict decoding to work.

    This issue now covers the detection half: reject an all-nil Filter (and all-nil check) in validateRolePerms, reject filter: under an insert: role entry, and make POST /v1/ops/policy/validate stop 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 — validateRolePerms runs only via Store.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.

  5. moved this from Backlog to In progress in WaveHouse Task Boardon Sep 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/configConfig file, config knobs, hot-reloadarea/policyAccess control policies (Hasura-style)breaking-changeBreaking change to public API, CLI, or configbugSomething isn't workingsecuritySecurity-sensitive issue or fix

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions