Skip to content

perf(rls): wrap current_setting() in the generated tenant policy for per-statement evaluation#1468

Open
dmitrymaranik wants to merge 4 commits into
archtechx:masterfrom
dmitrymaranik:perf/wrap-rls-initplan
Open

perf(rls): wrap current_setting() in the generated tenant policy for per-statement evaluation#1468
dmitrymaranik wants to merge 4 commits into
archtechx:masterfrom
dmitrymaranik:perf/wrap-rls-initplan

Conversation

@dmitrymaranik

@dmitrymaranik dmitrymaranik commented Jul 4, 2026

Copy link
Copy Markdown

archtechx/tenancy's RLS policy generator emits current_setting('<tenant setting>') unwrapped in the policy predicate. Postgres re-evaluates a bare current_setting(...) once per row the policy scans; wrapping it in a scalar subquery — (select current_setting(...)) — makes it a per-statement InitPlan the planner evaluates once and caches. It's predicate-equivalent (tenant isolation unchanged) and the documented Postgres RLS performance pattern; the benefit grows with table size.

This wraps the generated call and keeps the test assertions in sync (TraitRLSManager.php, TableRLSManager.php, TraitManagerTest.php, TableManagerTest.php). I couldn't run the PHP / Laravel test suite locally — if CI surfaces another assertion on the generated policy SQL, point me at it and I'll update it.

Spotted with pgrls (an open-source Postgres RLS analyzer). Happy to adjust.

Summary by CodeRabbit

  • Bug Fixes
    • Improved tenant-aware access checks so generated database policies evaluate the current tenant value more reliably.
    • Updated policy behavior across direct and nested relationships to keep row-level security consistent in more query paths.

@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7677f072-a4c1-4d21-86a8-d49447535e4c

📥 Commits

Reviewing files that changed from the base of the PR and between 3156c87 and 61410b5.

📒 Files selected for processing (4)
  • src/RLS/PolicyManagers/TableRLSManager.php
  • src/RLS/PolicyManagers/TraitRLSManager.php
  • tests/RLS/TableManagerTest.php
  • tests/RLS/TraitManagerTest.php

📝 Walkthrough

Walkthrough

This PR modifies SQL generation in TableRLSManager and TraitRLSManager so that current_setting(...) calls used in RLS policy predicates are wrapped in a subselect (select current_setting(...)). Corresponding test expectations were updated to match the new SQL output.

Changes

RLS Policy SQL Generation

Layer / File(s) Summary
Table-based RLS policy generation
src/RLS/PolicyManagers/TableRLSManager.php, tests/RLS/TableManagerTest.php
generateQuery() wraps current_setting(...) in a subselect for the tenants-table predicate; test expectations updated for authors, posts, comments, and nested custom-path policies.
Trait-based RLS policy generation
src/RLS/PolicyManagers/TraitRLSManager.php, tests/RLS/TraitManagerTest.php
Direct and indirect RLS policy query generation wraps current_setting(...) in a subselect; test expectations updated for posts and comments policies.

Estimated code review effort: 1 (Trivial) | ~5 minutes

Poem

A subselect hop, a tiny wrap,
current_setting takes a nap
inside parens, safe and snug,
tests all pass with a happy shrug,
hop hop hooray, no bugs to trap! 🐰

🚥 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 describes the main change: wrapping current_setting() in generated tenant RLS policies for per-statement evaluation.
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

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@codecov

codecov Bot commented Jul 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 86.37%. Comparing base (3156c87) to head (61410b5).

Additional details and impacted files
@@            Coverage Diff            @@
##             master    #1468   +/-   ##
=========================================
  Coverage     86.37%   86.37%           
  Complexity     1200     1200           
=========================================
  Files           186      186           
  Lines          3524     3524           
=========================================
  Hits           3044     3044           
  Misses          480      480           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@stancl

stancl commented Jul 4, 2026

Copy link
Copy Markdown
Member

Hi, thanks for the PR.

Can you provide a little more context about the mechanics of the cache involved?

@dmitrymaranik

Copy link
Copy Markdown
Author

Hi @stancl, thanks for taking a look!

The "cache" here is Postgres's InitPlan, not an application-level cache.

current_setting('my.current_tenant') is a STABLE function, so in a predicate like tenant_id::text = current_setting('my.current_tenant') Postgres evaluates that call once per row the query scans — it can't fold it at plan time (it isn't IMMUTABLE). On a 100k-row scan that's 100k calls.

Wrapping it as (select current_setting('my.current_tenant')) makes it an uncorrelated scalar subquery. Because it doesn't reference the outer row, the planner hoists it into an InitPlan that runs once per statement and reuses the cached result ($0) for every row — the same scan becomes a single call.

It stays semantically equivalent: the GUC is set once (via set_config/SET) before the query and is constant for the statement's lifetime, so caching the value can't change which rows match — tenant isolation is identical. It's the optimization Postgres/Supabase document for RLS (call functions with select), and you can confirm it in EXPLAIN: the wrapped form shows InitPlan 1 (returns $0) evaluated once, versus the bare form re-evaluating per row. The win scales with the number of rows the policy filters.

Happy to add a short benchmark or an inline comment in the PolicyManager if that'd be useful. It came up via pgrls flagging the generated policies (PERF001).

@stancl

stancl commented Jul 4, 2026

Copy link
Copy Markdown
Member

So just to confirm my understanding — this is specifically at query time, the RLS policy itself would be evaluated for each row during a query (that may be trying to select a row from a large table). If we make it a subquery, the result of that subquery will be cached (for the duration of the query?) and therefore only run once.

@dmitrymaranik

Copy link
Copy Markdown
Author

Exactly right.

At query time the predicate is applied to each candidate row the scan touches, so a bare current_setting() fires once per row. Wrapping it in (select …) makes Postgres evaluate it a single time as an InitPlan at the start of that statement and reuse the cached value for every row in the same query.

And yes on the scope — it's cached per statement execution: each new query re-runs the InitPlan once, then caches it for that query's rows. Nothing persists across queries, so there's no staleness — a fresh query always reads the current GUC. Net effect: the tenant check goes from O(rows) current_setting calls to O(1) per query, with identical results.

Happy to drop a one-line comment above the generated policy explaining the (select …) rationale if you'd like it documented for future readers.

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