Policies
A policy is an ORM-agnostic value object that renders a PostgreSQL USING
and/or WITH CHECK expression. Each reads the current identity from a session
variable via current_setting('rls.<key>').
The three policy types
| Policy | Row is visible when… |
|---|---|
TenantPolicy(name, column="tenant_id") | the tenant column matches rls.tenant_id |
UserPolicy(name, column="owner_id") | the user column matches rls.user_id |
CustomPolicy(name, using=…, check=…) | your raw predicate holds (injection-screened) |
TenantPolicy and UserPolicy
These cover the common case: a row belongs to a tenant or a user, and is visible only when that column equals the current context value.
from fastapi_rls import TenantPolicy, UserPolicy
TenantPolicy("tenant_isolation", column="tenant_id")
UserPolicy("owner_only", column="owner_id")
You can name the column explicitly with column=, or use the django-style
tenant_field= / user_field= alias, which appends _id to match Django's
foreign-key column convention:
TenantPolicy("tenant_isolation", tenant_field="organization") # → column "organization_id"
Pass column= or the *_field= alias, never both.
Both compile to the same shape — a scalar subquery wrapping current_setting so
PostgreSQL evaluates the read once per statement (an InitPlan) rather than
once per candidate row. This is the documented RLS performance pattern:
("tenant_id" = (SELECT NULLIF(current_setting('rls.tenant_id', true), '')::integer))
NULLIF(…, '') means "no context set" collapses to NULL, so the predicate is
false and the row is invisible — absent identity sees nothing, rather than
everything.
CustomPolicy
When neither fits, write the predicate yourself:
from fastapi_rls import CustomPolicy
CustomPolicy(
"published_or_owned",
using="status = 'published' "
"OR owner_id = (SELECT NULLIF(current_setting('rls.user_id', true), '')::integer)",
)
CustomPolicy expressions are screened for statement terminators (;),
comments (--, /* */), and DML/DDL keywords (DROP, ALTER, INSERT,
UPDATE, DELETE, …). This is a guardrail, not a sandbox — prefer
TenantPolicy/UserPolicy where they fit, and treat any dynamic value inside a
CustomPolicy as something you must construct yourself, safely.
Common options
Every policy accepts these (via BasePolicy):
| Option | Default | Meaning |
|---|---|---|
operation | "ALL" | ALL / SELECT / INSERT / UPDATE / DELETE |
permissive | True | PERMISSIVE; set False for a RESTRICTIVE policy |
roles | config default_roles ("public") | the TO clause |
table | None | bind at construction (core/registry use) or later via the mixin |
key_prefix | "rls" | session-variable namespace |
TenantPolicy/UserPolicy additionally take context_key (default tenant_id
/ user_id) and context_type (default "integer"; also bigint, smallint,
uuid, text/string). context_type sets the cast applied to the session
value — match it to your column type.
USING vs WITH CHECK
USINGfilters which existing rows are visible (SELECT/UPDATE/DELETE).WITH CHECKconstrains which rows may be written (INSERT/UPDATE).
For TenantPolicy/UserPolicy with operation="ALL", the same predicate is
emitted as both — so a tenant can neither see nor insert another tenant's rows.
CustomPolicy lets you supply a different check= expression when write rules
differ from read rules.
Binding and the registry
A policy is bound once it knows its table. There are two paths:
- Explicit:
TenantPolicy("…", column="…", table="documents")— used by the CLI, Alembic, and the low-level registry. - Via the mixin: listing the policy in a model's
__rls_policies__binds it to__tablename__and registers it automatically.
Bound policies live in a PolicyRegistry grouped by table. The RLS facade,
the CLI, and sync all read from a registry. See the
SQLAlchemy guide for registration details and the
API reference for the full surface.