PostgreSQL Row Level Security (RLS) for FastAPI and SQLAlchemy applications
Project description
FastAPI RLS
PostgreSQL Row Level Security (RLS) for FastAPI and SQLAlchemy applications — enforce
multi-tenant and per-user data isolation at the database level, where it cannot be
bypassed by a forgotten WHERE clause.
This is the FastAPI/SQLAlchemy sibling of django-rls. The policy model, the session-variable context, and the generated DDL are intentionally the same; only the framework integration differs.
📖 Documentation: https://fastapi-rls.com (source in website/)
Features
- 🔒 Database-level Row Level Security using native PostgreSQL RLS
- 🏢 Tenant-based and user-based policies, plus raw
CustomPolicy - 🧩 ORM-agnostic core — policies, context, and DDL have zero framework dependencies; SQLAlchemy, FastAPI, and Alembic are optional adapters
- ⚡ Sync and async — works with
SessionandAsyncSession - 🛡️ Leak-proof by construction — identity is applied with
SET LOCALinside a per-request transaction, with a pool-checkout scrub as a defense-in-depth backstop - 🔑 Immutable identity —
user_id/tenant_idcannot be silently overridden mid-request - 🚀 FastAPI dependency + optional identity middleware
- 🧰 Alembic operation directives and a standalone
fastapi-rlsCLI - 🧪 Extensively tested against real PostgreSQL, including cross-tenant leak tests
Installation
pip install "fastapi-rls[all]" # everything
pip install "fastapi-rls[fastapi]" # FastAPI + SQLAlchemy
pip install "fastapi-rls[sqlalchemy]" # SQLAlchemy only
pip install "fastapi-rls[alembic,cli]" # migrations + CLI
Requirements: Python 3.10–3.13, PostgreSQL 13–17, SQLAlchemy 2.0, FastAPI ≥0.100. Every one of those versions is exercised in CI (see the test matrix).
Quick start
1. Declare policies on your models
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from fastapi_rls import TenantPolicy, UserPolicy
from fastapi_rls.adapters.sqlalchemy import RLSMixin
class Base(DeclarativeBase):
pass
class Document(Base, RLSMixin):
__tablename__ = "documents"
__rls_policies__ = [
TenantPolicy("tenant_isolation", column="tenant_id"),
]
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[int] = mapped_column(index=True)
title: Mapped[str]
RLSMixin binds each policy to the table and registers it. (Call
collect_policies(Base) at startup if your declarative base doesn't propagate
__init_subclass__.)
2. Apply the policies to the database
from fastapi_rls import RLS
rls = RLS(engine=engine) # your SQLAlchemy Engine
rls.sync() # ENABLE + FORCE RLS and CREATE every registered policy
or from the CLI (no Alembic required):
fastapi-rls sync --url "$DATABASE_URL" --policies myapp.models
or inside an Alembic migration:
import fastapi_rls.alembic_ops # noqa: F401 — registers op.enable_rls / op.create_policy
def upgrade():
op.enable_rls("documents")
op.create_policy(TenantPolicy("tenant_isolation", column="tenant_id", table="documents"))
3. Wire the request context
from fastapi import Depends, FastAPI
from sqlalchemy.orm import Session
from fastapi_rls import RLS
rls = RLS(engine=engine)
def identity(user = Depends(get_current_user)) -> dict:
# fastapi-rls never authenticates — you resolve the principal, it propagates it.
return {"tenant_id": user.tenant_id}
get_session = rls.session_dependency(identity=identity)
app = FastAPI()
@app.get("/documents")
def list_documents(session: Session = Depends(get_session)):
return session.scalars(select(Document)).all() # PostgreSQL filters by tenant
Every query on that session is transparently scoped to the caller's tenant. A request with no context sees no rows, never everyone's.
Async is identical
rls = RLS(async_engine=async_engine)
get_session = rls.async_session_dependency(identity=identity)
@app.get("/documents")
async def list_documents(session: AsyncSession = Depends(get_session)):
result = await session.scalars(select(Document))
return result.all()
How it works
- A policy compiles to a PostgreSQL predicate like
tenant_id = (SELECT NULLIF(current_setting('rls.tenant_id', true), '')::integer)(thecurrent_settingread is wrapped in a scalar subquery so the planner evaluates it once per statement — the documented RLS performance pattern). - The session dependency opens a transaction and runs
SET LOCAL rls.tenant_id = …from the identity you resolved. - PostgreSQL enforces the predicate on every
SELECT/INSERT/UPDATE/DELETE. - Committing the transaction clears the
LOCALsetting; a pool-checkout scrub is a second line of defense. Identity can never survive into another request.
Policy types
| Policy | Use |
|---|---|
TenantPolicy(name, column="tenant_id") |
Row visible when the tenant column matches rls.tenant_id |
UserPolicy(name, column="owner_id") |
Row visible when the user column matches rls.user_id |
CustomPolicy(name, using=..., check=...) |
A raw predicate (injection-screened) when the above don't fit |
All support operation (ALL/SELECT/INSERT/UPDATE/DELETE), permissive=False for
RESTRICTIVE policies, roles=, and context_type (integer/bigint/uuid/text).
Configuration
Pass an RLSConfig to RLS(config=...) (or configure(...) the global one):
| Option | Default | Meaning |
|---|---|---|
require_context |
False |
Raise if no identity is set before a query |
transaction_per_request |
True |
Use SET LOCAL inside a per-request transaction |
reset_context_on_checkout |
True |
Install the pool-checkout scrub backstop |
audit_log |
False |
Emit structured logs on context set/clear |
default_roles |
"public" |
Default TO clause for policies |
key_prefix |
"rls" |
Session-variable namespace |
registered_context_keys |
() |
Extra keys to scrub during connection hygiene |
CLI
fastapi-rls sync --url "$DATABASE_URL" --policies myapp.models # reconcile
fastapi-rls plan --url "$DATABASE_URL" --policies myapp.models # dry run
fastapi-rls audit --url "$DATABASE_URL" --policies myapp.models # report drift
fastapi-rls enable --url "$DATABASE_URL" documents # enable + force
Security notes
- Connect as a non-superuser. PostgreSQL superusers and
BYPASSRLSroles ignore RLS entirely. Your application role must be neither. FORCE ROW LEVEL SECURITYis applied by default so the table owner is constrained too.- Table, column, role, and operation names are validated against a strict identifier
allowlist before reaching any DDL;
CustomPolicyexpressions are screened for statement terminators, comments, and DML/DDL keywords.
License
BSD 3-Clause License — see LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file fastapi_rls-0.1.0.tar.gz.
File metadata
- Download URL: fastapi_rls-0.1.0.tar.gz
- Upload date:
- Size: 28.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8c5d243bdef9d7f35f0ab1767ed849b2ce35c910b20786670b3808292e1759ba
|
|
| MD5 |
9a3854c6dff6fe4ca6dbd8095d2aef5f
|
|
| BLAKE2b-256 |
c4282dd8bfa30fb5f11264cfd2a0c7a939aa1f1e9f4793d4a3dc4a97afcc1e6a
|
Provenance
The following attestation bundles were made for fastapi_rls-0.1.0.tar.gz:
Publisher:
release.yml on kdpisda/fastapi-rls
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
fastapi_rls-0.1.0.tar.gz -
Subject digest:
8c5d243bdef9d7f35f0ab1767ed849b2ce35c910b20786670b3808292e1759ba - Sigstore transparency entry: 2305695831
- Sigstore integration time:
-
Permalink:
kdpisda/fastapi-rls@9274228250f914e148987dbf13ddf4d0e2be82ee -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/kdpisda
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@9274228250f914e148987dbf13ddf4d0e2be82ee -
Trigger Event:
release
-
Statement type:
File details
Details for the file fastapi_rls-0.1.0-py3-none-any.whl.
File metadata
- Download URL: fastapi_rls-0.1.0-py3-none-any.whl
- Upload date:
- Size: 32.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4e0368e372f6967268267730d565ea9da34a1795c5432a62fcb391ce87cc4552
|
|
| MD5 |
ad06905af80dffb3a1508cfb2e375900
|
|
| BLAKE2b-256 |
00f41e5f1eed8724bb402a89f06d615a6192f306d2ecdf1ee7f7bb6cf1f30425
|
Provenance
The following attestation bundles were made for fastapi_rls-0.1.0-py3-none-any.whl:
Publisher:
release.yml on kdpisda/fastapi-rls
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
fastapi_rls-0.1.0-py3-none-any.whl -
Subject digest:
4e0368e372f6967268267730d565ea9da34a1795c5432a62fcb391ce87cc4552 - Sigstore transparency entry: 2305695932
- Sigstore integration time:
-
Permalink:
kdpisda/fastapi-rls@9274228250f914e148987dbf13ddf4d0e2be82ee -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/kdpisda
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@9274228250f914e148987dbf13ddf4d0e2be82ee -
Trigger Event:
release
-
Statement type: