This version is in beta. Some features may change before release.

RLS plugin

Postgres Row-Level Security policies declared in the App builder and applied idempotently at boot.

umbral-rls lets you declare Postgres Row-Level Security policies once, at App::build() time, and have them applied automatically every boot. The plugin handles ALTER TABLE ... ENABLE ROW LEVEL SECURITY and the idempotent DROP POLICY IF EXISTS + CREATE POLICY cycle so you never hand-write that boilerplate and never worry about the schema drifting between environments.

Install

Code
bash
cargo add umbral-rls

Quickstart

Add umbral-rls to your Cargo.toml, then wire it in the builder:

Code
rust
use umbral::prelude::*;
use umbral_rls::{Action, RlsPlugin};
 
let app = App::builder()
.settings(settings)
.database("default", pool)
.plugin(
RlsPlugin::new()
.policy("post", "user_can_read", Action::Select,
"user_id = NULLIF(current_setting('app.user_id'), '')::int")
.policy_with_check(
"post",
"user_can_insert",
Action::Insert,
"user_id = NULLIF(current_setting('app.user_id'), '')::int",
"user_id = NULLIF(current_setting('app.user_id'), '')::int AND status <> 'banned'",
),
)
.build()?;

.policy(table, name, action, using) is enough for read-only predicates. .policy_with_check(table, name, action, using, with_check) adds an explicit WITH CHECK clause - useful when INSERT or UPDATE rules differ from the read predicate. Calling .policy() on a table auto-enables RLS on it; a separate .enable_on("post") call is only needed for tables that need RLS enabled without any policy attached yet.

The policy SQL

At on_ready time the plugin runs these statements in order:

Code
sql
-- for every table registered via .enable_on() or .policy()
ALTER TABLE "post" ENABLE ROW LEVEL SECURITY;
 
-- for every policy
DROP POLICY IF EXISTS "user_can_read" ON "post";
CREATE POLICY "user_can_read" ON "post"
FOR SELECT
USING (user_id = NULLIF(current_setting('app.user_id'), '')::int);

The DROP IF EXISTS + CREATE pair is the idempotency mechanism. Postgres has no CREATE OR REPLACE POLICY, so this is the correct pattern. Re-running the app (or restarting after a crash) applies the current definitions without error. Policy names and table names are double-quote escaped; the USING and WITH CHECK expressions are passed through verbatim - they are SQL you wrote and are responsible for.

Warning

Plain ENABLE ROW LEVEL SECURITY does not apply to the role that owns the table. In the default umbral setup there is one DATABASE_URL: cargo run -- migrate creates and therefore owns the tables, and the app connects at runtime with the same role - so ENABLE alone would leave every policy silently bypassed while pg_policies still listed them.

The plugin therefore also emits ALTER TABLE FORCE ROW LEVEL SECURITY for every registered table, so the owner is subject to its own policies.

FORCE does not cover BYPASSRLS or superuser roles, which ignore RLS entirely. Do not run the app as a superuser. Verify isolation with a two-user test (insert rows as user 1 and user 2, set app.user_id, assert each sees only its own) before trusting RLS in production.

Setting user context per request

Policies referencing current_setting('app.user_id') only work when that variable is set on the connection the query runs on. Postgres will not infer it. One builder call does the wiring:

Code
rust
App::builder()
.plugin(SessionsPlugin::default())
.plugin(AuthPlugin::new().with_db_session_var("app.user_id"))
.plugin(RlsPlugin::new().policy(
"post", "own", Action::All,
"author_id = NULLIF(current_setting('app.user_id'), '')",
))

with_db_session_var resolves the user from the authenticated session — never a client-supplied header — and a deactivated account resolves to anonymous. It puts the id on the request's RouteContext, and the Postgres pool applies it in its before_acquire hook: RESET ALL to clear whatever a previous request left on the pooled connection, then set_config for this request's variables. So every connection your handler touches has the right value, and none of it leaks to the next request. You do not need to pin a connection or wrap the request in a transaction.

Warning

Write NULLIF(current_setting('app.user_id'), ''), not the bare current_setting(...). The variable is set on every request, to the empty string when nobody is logged in — because current_setting on a variable that was never set raises unrecognized configuration parameter, which would turn every logged-out request into a 500 instead of an empty result set. NULLIF turns the anonymous case into NULL, and NULL compares false, so anonymous callers see no rows.

Danger

Do not enable RLS on auth_user or session. The layer reads those tables to discover who the caller is, which necessarily happens before any variable has been set.

It costs one session read plus one user read per request, so it is off by default. Unlike with_user_in_templates it cannot be lazy — the value must be on the connection before your handler's first query, not after something asks for it.

Group X can read but not write

This is the framework-enforced permission the ORM deliberately does not implement. One SELECT policy, and a write policy gated on group membership. Application code cannot bypass it: the database refuses the row.

Code
rust
RlsPlugin::new()
.policy(
"post", "read_signed_in", Action::Select,
"NULLIF(current_setting('app.user_id'), '') IS NOT NULL",
)
.policy_with_check(
"post", "write_editors_only", Action::Insert,
"true",
"EXISTS (SELECT 1 FROM permissions_usergroup ug \
JOIN permissions_group g ON g.id = ug.group_id \
WHERE g.name = 'editors' \
AND ug.user_id = NULLIF(current_setting('app.user_id'), ''))",
)

permissions_usergroup / permissions_group are the tables umbral-permissions migrates, so the policy reads the same group membership the admin UI edits. A member of viewers gets rows on SELECT and new row violates row-level security policy on INSERT — from Postgres, not from a middleware someone can forget to mount.

Info

Why not enforce this in the ORM? Because has_perm() is itself an ORM query, so a QuerySet guard that consulted it would recurse; because the read terminals return sqlx::Error and a denial has no honest home there; and because the ambient "current user" it needs would be a second process-wide global. RLS puts the check in the one place no application code can route around. See planning/archive/gaps3-done.md #45.

SQLite story

Warning

umbral-rls is Postgres-only. When the active backend is SQLite, the plugin logs a tracing::warn and returns Ok(()) from on_ready - it does not refuse to boot. All declared tables and policies are silently skipped.

This follows umbral's convention for backend-specific features: the feature is absent, not fatal, on an incompatible backend. If you want a hard failure on misconfiguration, check the pool variant yourself in your boot path:

Code
rust
use umbral::db::{pool_dispatched, DbPool};
 
match pool_dispatched() {
DbPool::Postgres(_) => { /* ok */ }
DbPool::Sqlite(_) => panic!("this app requires Postgres (RLS)"),
}

Reboot semantics

Policies are applied at every boot, but they are not removed when you delete them from the builder. If you drop a policy from the RlsPlugin chain, the Postgres policy object remains on the table until you remove it explicitly:

Code
sql
DROP POLICY "user_can_read" ON "post";

This is intentional. The plugin cannot diff what is in the database against what is in the builder without taking a broader migration ownership it doesn't have. Treating RLS policies like schema migrations - where removals produce DROP operations - is a planned enhancement; for now, explicit cleanup is the safe shape.

The spec and implementation live in plugins/umbral-rls/src/lib.rs and docs/specs/ (Phase 4.5).