Skip to main content

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

PolicyRow 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):

OptionDefaultMeaning
operation"ALL"ALL / SELECT / INSERT / UPDATE / DELETE
permissiveTruePERMISSIVE; set False for a RESTRICTIVE policy
rolesconfig default_roles ("public")the TO clause
tableNonebind 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

  • USING filters which existing rows are visible (SELECT/UPDATE/DELETE).
  • WITH CHECK constrains 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.