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
cargo add umbral-rlsQuickstart
Add umbral-rls to your Cargo.toml, then wire it in the builder:
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:
-- for every table registered via .enable_on() or .policy()ALTER TABLE "post" ENABLE ROW LEVEL SECURITY; -- for every policyDROP 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.
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 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:
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.
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.
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.
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.
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
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:
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:
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).