Skip to content

DEV-1718: refactor RLS SessionPolicy into a single ruleset - #260

Merged
whimo merged 6 commits into
mainfrom
egor/dev-1718-separate-rls-filter-set-classes
Jul 31, 2026
Merged

DEV-1718: refactor RLS SessionPolicy into a single ruleset#260
whimo merged 6 commits into
mainfrom
egor/dev-1718-separate-rls-filter-set-classes

Conversation

@ZmeiGorynych

@ZmeiGorynych ZmeiGorynych commented Jul 27, 2026

Copy link
Copy Markdown
Member

Summary

Replaces SessionPolicy.data_filters (a list of ColumnFilterRule / JoinFilterRule plus a mandatory block-backstop) with a single required ruleset, one of two mutually-exclusive kinds. Hard break — no data_filters shim (SessionPolicy is engine-init state only, never persisted).

  • ColumnFilterRuleset — the broadcast column filter (mirrors the old single ColumnFilterRule: column/value/on_unapplicable). The has_column introspection probe is now used only on this branch.
  • JoinFilterRuleset — a single-anchor model: one table+column+value holds the tenant identifier; nested JoinFilterRules reach it via explicit join paths (correlated EXISTS); a whitelist names shared pass-through tables. Classification is fully structural / DB-free: anchor → direct wrap, target → EXISTS, whitelist → passthrough, anything else → fail closed (the whitelist replaces the old block-backstop).

Key design points

  • Nested JoinFilterRule drops column/value/name (hoisted to the ruleset). join_path may begin OR end with the anchor; oriented_hops() normalizes to target-first. The anchor must appear exactly once in the oriented path (rejects master-as-intermediate). _build_exists re-checks the oriented terminal reaches the anchor (defensive vs a corrupt model_copy).
  • kind discriminator is explicit — no inference; a kind-less dict ruleset raises.
  • No name attribute anywhere; ForcedFilterError drops rule_name.
  • ruleset is required — no-filtering is policy=None, so a bare SessionPolicy() raises.
  • Trust model documented: policy-authored table/column/hop names are emitted verbatim (never introspected); bare names match any schema; the whitelist governs only the agent query's direct table access.

Process

Spec + Codex plan review (round 3) + Codex test-coverage review (Step 5) were completed before implementation; all Codex findings were folded in or documented.

Tests

  • 6541 non-integration pass, 23 RLS integration (local SQLite) pass, ruff clean.
  • Rewrote test_policy_models.py, test_session_policy.py, test_session_policy_engine.py; updated test_client_policy.py, test_query_cache.py, test_integration_rls.py.

Docs

  • docs/concepts/row-level-security.md rewritten (two ruleset kinds + Trust-model note; backstop/override sections removed).
  • docs/getting-started/python.md, CLAUDE.md, .claude/skills/slayer-overview.md, and the RLS notebook updated.

Closes DEV-1718.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Introduced a unified row-level security policy format with column-based and join-based filtering.
    • Added explicit join-path configuration, table whitelisting, and fail-closed behavior for unrecognized or unverifiable tables.
    • Added correlated filtering for join targets while preserving shared whitelisted tables.
  • Breaking Changes

    • Replaced the previous policy configuration format with a required ruleset.
    • Updated the forced-filter error interface and removed the deprecated rule-name field.
  • Documentation

    • Updated guides, examples, and architecture documentation to explain the new policy model and configuration rules.

Replace SessionPolicy.data_filters (a list of ColumnFilterRule/JoinFilterRule
with a mandatory block-backstop) with a single required ruleset that is one of
two mutually-exclusive kinds:

- ColumnFilterRuleset: the broadcast column filter (mirrors the old single
  ColumnFilterRule; on_unapplicable governs a missing column; has_column probe
  is now used only on this branch).
- JoinFilterRuleset: a single-anchor model (table+column+value) with explicit
  JoinFilterRules and a whitelist. Classification is fully structural/DB-free:
  anchor -> direct wrap, target -> correlated EXISTS, whitelist -> passthrough,
  anything else -> fail closed (the whitelist replaces the block-backstop).

Nested JoinFilterRule drops column/value/name; join_path may begin OR end with
the anchor (oriented_hops normalizes to target-first); the anchor must appear
exactly once in the oriented path. The kind discriminator is explicit (no
inference). No name attribute anywhere; ForcedFilterError drops rule_name.
Hard break: no data_filters shim.

Updates the SQL rewrite, engine wiring (_apply_policy / _policy_has_join_rules),
all policy tests, the RLS concept doc + notebook, CLAUDE.md and the overview
skill.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@linear

linear Bot commented Jul 27, 2026

Copy link
Copy Markdown

DEV-1718

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR replaces SessionPolicy.data_filters with one discriminated ColumnFilterRuleset or JoinFilterRuleset. It updates SQL rewriting, ClickHouse handling, engine wiring, tests, examples, and RLS documentation.

Changes

Row-level security ruleset migration

Layer / File(s) Summary
Ruleset policy contract
slayer/core/policy.py, slayer/core/errors.py, tests/test_policy_models.py
Adds immutable column and join rulesets, explicit join-path validation, anchor and whitelist invariants, required SessionPolicy.ruleset, and updated ForcedFilterError fields.
Ruleset-driven SQL rewriting
slayer/sql/session_policy.py, tests/test_session_policy.py
Applies column predicates through physical-table probes and join predicates through correlated EXISTS; preserves whitelist tables and fails closed for unhandled or unconfirmed tables.
Engine and ClickHouse policy wiring
slayer/engine/query_engine.py, tests/test_session_policy_engine.py, tests/test_client_policy.py, tests/test_query_cache.py
Updates policy dispatch, join detection, ClickHouse version gating, correlated-subquery settings, and policy construction fixtures.
End-to-end RLS validation
tests/integration/test_integration_rls.py
Validates anchor scoping, join-target filtering, whitelist passthrough, unlisted-table failures, and the new policy construction API.
Documentation and examples
docs/concepts/row-level-security.md, docs/examples/10_row_level_security/row_level_security_nb.ipynb, docs/getting-started/python.md, .claude/skills/slayer-overview.md, DECISIONS.md
Updates RLS terminology, configuration examples, join ruleset behavior, fail-closed semantics, and the recorded design decision.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

  • MotleyAI/slayer#205: Introduces the forced-filter policy and SQL rewriting behavior that this PR redesigns.
  • MotleyAI/slayer#220: Adds the join-based RLS flow that this PR reshapes into JoinFilterRuleset.

Suggested reviewers: whimo

Sequence Diagram(s)

sequenceDiagram
  participant QueryEngine
  participant SessionPolicy
  participant apply_session_policy
  participant SQLAst
  QueryEngine->>SessionPolicy: provide ruleset
  QueryEngine->>apply_session_policy: rewrite final SQL
  apply_session_policy->>SQLAst: inspect physical tables
  apply_session_policy->>SQLAst: emit column predicates or correlated EXISTS
  SQLAst-->>QueryEngine: return scoped SQL
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely identifies the RLS SessionPolicy refactor from multiple data filters to a single ruleset.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch egor/dev-1718-separate-rls-filter-set-classes

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
slayer/sql/session_policy.py (1)

194-200: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Optional: fold _terminal_predicate into _build_predicate.

The two builders differ only by the table qualifier on the column.

♻️ Single predicate builder
-def _build_predicate(column: str, value) -> exp.Expression:
-    """Unqualified ``column = value`` / ``column IN (...)`` predicate."""
-    col = exp.column(column)
+def _build_predicate(column: str, value, *, table: Optional[str] = None) -> exp.Expression:
+    """``column = value`` / ``column IN (...)``, optionally qualified by
+    ``table`` — values always via ``exp.convert`` (injection-safe)."""
+    col = exp.column(column, table=table) if table else exp.column(column)
     if isinstance(value, tuple):
         return exp.In(this=col, expressions=[exp.convert(v) for v in value])
     return exp.EQ(this=col, expression=exp.convert(value))

Then call _build_predicate(ruleset.column, ruleset.value, table=_hop_alias(len(hops) - 1)) at line 245 and drop _terminal_predicate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@slayer/sql/session_policy.py` around lines 194 - 200, Optionally remove the
redundant _terminal_predicate helper and extend _build_predicate to accept the
table qualifier needed for its column expression. Update the terminal predicate
construction near the ruleset-building flow to call _build_predicate with
ruleset.column, ruleset.value, and _hop_alias(len(hops) - 1), preserving the
existing scalar and tuple/IN behavior.
slayer/core/policy.py (1)

346-383: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Anchor-target guard ordering makes its error message unreachable, and the test can't tell. In _validate_anchor_reachability the path checks run before the "a join rule may not target the anchor" check, so a rule targeting the anchor trips the "anchor must appear exactly once" branch instead and reports a misleading reason. Both sites still fail closed — this is diagnostics quality plus test precision.

  • slayer/core/policy.py#L346-L383: hoist the _table_names_match(rule.target_table, master) guard to the top of the for rule in self.joins loop, before rule.oriented_hops().
  • tests/test_policy_models.py#L528-L538: assert on the message (e.g. pytest.raises(ValidationError, match="may not target the anchor")) so the test pins the intended guard rather than any ValidationError.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@slayer/core/policy.py` around lines 346 - 383, The anchor-target validation
in _validate_anchor_reachability must run before path orientation and
reachability checks so it produces the intended “may not target the anchor”
error. Update slayer/core/policy.py lines 346-383 by moving that guard to the
start of the loop, and update tests/test_policy_models.py lines 528-538 to
assert ValidationError with a message matching “may not target the anchor”.
tests/test_session_policy_engine.py (1)

394-401: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract _ch_ds() outside the pytest.raises block.

Matches the SonarCloud finding: two call expressions (_ch_ds() and _apply_policy(...)) sit inside the with pytest.raises(...) block. The very next test (test_apply_policy_join_rule_ok_when_version_supported) already follows the safer pattern of resolving ds beforehand.

♻️ Proposed fix
 def test_apply_policy_join_rule_fails_closed_when_version_unknown(
     join_engine, monkeypatch
 ):
     monkeypatch.setattr(join_engine, "_column_present", lambda **k: True)
+    ds = _ch_ds()
     with pytest.raises(ForcedFilterError):
         join_engine._apply_policy(
-            sql="SELECT * FROM orders", dialect="clickhouse", datasource=_ch_ds()
+            sql="SELECT * FROM orders", dialect="clickhouse", datasource=ds
         )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_session_policy_engine.py` around lines 394 - 401, Extract the
_ch_ds() call into a ds variable before the pytest.raises block in
test_apply_policy_join_rule_fails_closed_when_version_unknown, then pass ds to
_apply_policy inside the block. Keep the existing assertion and policy
invocation behavior unchanged.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/examples/10_row_level_security/row_level_security_nb.ipynb`:
- Around line 17-19: Update the introductory notebook text near the
SessionPolicy example to clarify that the column ruleset is demonstrated first,
followed later by a separate join policy/engine for the customers table. Replace
the sequential “First ... then ...” wording with language that does not imply
both rulesets are composed in one SessionPolicy.

---

Nitpick comments:
In `@slayer/core/policy.py`:
- Around line 346-383: The anchor-target validation in
_validate_anchor_reachability must run before path orientation and reachability
checks so it produces the intended “may not target the anchor” error. Update
slayer/core/policy.py lines 346-383 by moving that guard to the start of the
loop, and update tests/test_policy_models.py lines 528-538 to assert
ValidationError with a message matching “may not target the anchor”.

In `@slayer/sql/session_policy.py`:
- Around line 194-200: Optionally remove the redundant _terminal_predicate
helper and extend _build_predicate to accept the table qualifier needed for its
column expression. Update the terminal predicate construction near the
ruleset-building flow to call _build_predicate with ruleset.column,
ruleset.value, and _hop_alias(len(hops) - 1), preserving the existing scalar and
tuple/IN behavior.

In `@tests/test_session_policy_engine.py`:
- Around line 394-401: Extract the _ch_ds() call into a ds variable before the
pytest.raises block in
test_apply_policy_join_rule_fails_closed_when_version_unknown, then pass ds to
_apply_policy inside the block. Keep the existing assertion and policy
invocation behavior unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 046d8b15-2dce-4043-bfc1-49357e3c78b6

📥 Commits

Reviewing files that changed from the base of the PR and between 9595b87 and b3d4c2b.

📒 Files selected for processing (15)
  • .claude/skills/slayer-overview.md
  • CLAUDE.md
  • docs/concepts/row-level-security.md
  • docs/examples/10_row_level_security/row_level_security_nb.ipynb
  • docs/getting-started/python.md
  • slayer/core/errors.py
  • slayer/core/policy.py
  • slayer/engine/query_engine.py
  • slayer/sql/session_policy.py
  • tests/integration/test_integration_rls.py
  • tests/test_client_policy.py
  • tests/test_policy_models.py
  • tests/test_query_cache.py
  • tests/test_session_policy.py
  • tests/test_session_policy_engine.py

Comment thread docs/examples/10_row_level_security/row_level_security_nb.ipynb Outdated
ZmeiGorynych and others added 5 commits July 28, 2026 07:13
- Codex (major): reject a qualified anchor reached via a less-qualified
  join-path endpoint (_reaches_anchor), so a qualified anchor's schema is never
  dropped from the emitted EXISTS. Add construction tests.
- Codex (minor): _build_exists wraps oriented_hops() ValueError as a
  fail-closed ForcedFilterError at the SQL boundary. Add a corrupted-copy test.
- Sonar S5778 (15): hoist inline factory/constructor calls out of every
  pytest.raises block so each has exactly one throwing call.
- CodeRabbit: fold _terminal_predicate into _build_predicate(table=...); reword
  the RLS notebook intro to clarify the two rulesets are shown separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The SQL-boundary fail-closed check in _build_exists used _table_names_match,
which would accept a model_copy-corrupted qualified anchor reached via a bare
terminal (dropping the schema qualifier). Use _reaches_anchor to mirror the
construction-time validator so such a corrupted policy fails closed instead of
emitting the tenant predicate against the wrong-schema table. Add a pinning
test. (Codex round-2 finding.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…QL boundary

Codex round-3: _build_exists re-checked reachability but not the "anchor
appears exactly once" invariant, so a model_copy-corrupted anchor-as-intermediate
path could slip past the SQL boundary and emit a mis-scoped EXISTS.

Extract _validate_join_rule_anchor (reachability + anchor-exactly-once +
not-targeting-anchor, returning oriented hops) as the single source of truth,
called by both the JoinFilterRuleset validator and _build_exists (wrapping
ValueError as a fail-closed ForcedFilterError). No invariant drift between the
two layers. Add a pinning test for the corrupted anchor-as-intermediate case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolves the CLAUDE.md conflict: main decluttered CLAUDE.md and moved the
design-decision log into DECISIONS.md, deleting the detailed RLS bullets this
branch had rewritten. Took main's CLAUDE.md and recorded the DEV-1718 ruleset
redesign as a new appended DECISIONS.md entry instead.
Cut the RLS policy/rewrite docs down to what a reader needs to orient quickly,
leaving the details to the code. Drops the rationale essays, the restated
mechanism at every call site, and the ticket/review references.
@sonarqubecloud

Copy link
Copy Markdown

@whimo
whimo merged commit cf81b67 into main Jul 31, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants