Declarative PostgreSQL row level security for SQLAlchemy and Alembic
Project description
rls
Declarative PostgreSQL row level security for SQLAlchemy and Alembic.
A fork of DelfinaCare/rls (MIT), reworked from the ground up
while keeping its public spirit: a model states its own row level security policies as
__rls_policies__, and Alembic autogenerate creates, diffs, and drops them for you. See
Changes from upstream for what moved and why.
Installation
pip install rls
Usage
Defining policies
Attach __rls_policies__ to any SQLAlchemy mapped class to declare which policies apply to it. A
policy names exactly one command, select, insert, update, or delete, never ALL: a FOR ALL policy's USING clause is also OR-ed into a table's SELECT visibility by Postgres, so a
narrower write predicate would leak past a table's read predicate.
from sqlalchemy import Column, ForeignKey, Integer, String
from sqlalchemy.orm import DeclarativeBase, relationship
import rls
class Base(DeclarativeBase):
pass
class Item(Base):
__tablename__ = "items"
id = Column(Integer, primary_key=True)
title = Column(String)
owner_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"))
def __rls_policies__(self=None) -> list[rls.Policy]:
owner = rls.current_setting("account_id", Integer(), prefix="app")
mine = Item.owner_id == owner
return [
rls.Policy(name="items_read", command=rls.Command.select, using=mine),
rls.Policy(name="items_insert", command=rls.Command.insert, check=mine),
rls.Policy(name="items_update", command=rls.Command.update, using=mine, check=mine),
rls.Policy(name="items_delete", command=rls.Command.delete, using=mine),
]
__rls_policies__ may be a plain list (built from bare, table-unqualified sqlalchemy.column()
stand-ins, the shape upstream used) or a zero-argument callable, as above, so a policy can reference
a model's own mapped columns (Item.owner_id) instead: the callable form runs once the table
actually exists, after SQLAlchemy has finished mapping the class.
current_setting
rls.current_setting(name, type_, prefix) reads a Postgres session variable (SET LOCAL <prefix>.<name>) as a scalar-subquery InitPlan, evaluated once per query rather than once per
row. The GUC namespace is a parameter here, never a module constant, so more than one registry can
share a process without colliding.
Registering a declarative base
import rls
rls.register(Base)
Call this once per declarative base during application setup, before Alembic autogenerate runs
against it or before calling rls.create_policies directly. Pass grant_role="my_app_role" to also
have every protected table GRANT SELECT, INSERT, UPDATE, DELETE to that role.
Alembic
Importing rls registers its autogenerate operations, comparator, and renderers as a side effect;
just make sure rls (and rls.register(Base)) is imported before env.py runs autogenerate:
import rls
rls.register(Base)
target_metadata = Base.metadata
Generate a revision and run alembic upgrade head as normal, policies are created, diffed, or
dropped automatically. A table with no FORCE ROW LEVEL SECURITY (or no row security at all) gets
the whole-table op.apply_rls(...) bootstrap; an already-protected table gets a per-policy diff
instead, op.create_rls_policy(...)/op.drop_rls_policy(...), so a single changed clause never
reapplies an entire table's policy set.
If you are not using Alembic, call rls.create_policies(metadata, connection) directly after
metadata.create_all().
Using policies at runtime
Policies are enforced through RlsSession, a drop-in replacement for SQLAlchemy's Session. Supply
a Pydantic context object whose fields match the names your policies read via current_setting,
plus a bound engine:
from pydantic import BaseModel
from rls import RlsSession
class MyContext(BaseModel):
account_id: int
session = RlsSession(context=MyContext(account_id=1), guc_prefix="app", bind=engine)
rows = session.execute(text("SELECT * FROM items")).fetchall()
guc_prefix (default "rls") must match the prefix your policies were built with. There is no
bypass branch baked into any policy by default; a policy that wants an escape hatch composes
rls.bypass_clause(prefix) into its own using/check expression, and session.bypass_rls()
opens a block where that flag reads true:
with session.bypass_rls() as session:
rows = session.execute(text("SELECT * FROM items")).fetchall()
RlsSessioner
For an app that builds a session per request or operation, RlsSessioner wraps a sessionmaker
(class_=RlsSession) and a ContextGetter subclass into a ready-to-use session factory. It takes
no framework dependency; wrap it in your own framework's dependency-injection mechanism (FastAPI
Depends, a Flask decorator, or a bare with block) as needed.
from sqlalchemy.orm import sessionmaker
from rls import ContextGetter, RlsSession, RlsSessioner
class MyContextGetter(ContextGetter):
def get_context(self, *args, **kwargs) -> MyContext:
return MyContext(account_id=kwargs["account_id"])
session_maker = sessionmaker(class_=RlsSession, bind=engine)
sessioner = RlsSessioner(sessionmaker=session_maker, context_getter=MyContextGetter())
with sessioner(account_id=22) as session:
rows = session.execute(text("SELECT * FROM items")).fetchall()
Views over a protected table
A plain view runs as its owner, bypassing row level security on every table it selects from
entirely; a view carries no rows or policies of its own to enforce. Postgres only lets a view defer
to the querying role's own row level security starting with version 15, behind the
security_invoker reloption. Any view over a table this library protects must set it:
import rls
statement = rls.security_invoker_view("items_summary", "SELECT owner_id, count(*) FROM items GROUP BY owner_id")
Without WITH (security_invoker = true), the view silently reintroduces exactly the leak row level
security exists to close.
Verifying the live schema
rls.verify_rls(connection, expected, declared) reads pg_class/pg_policies back and reports
every way the live schema disagrees with what is declared, independent of any pending migration, a
missing policy, one with a drifted clause, or a table that lost FORCE:
violations = rls.verify_rls(connection, expected={"items"}, declared=Base.metadata.info["rls_policies"])
assert violations == []
Changes from upstream
- Generic GUC namespace.
current_setting/RlsSessiontake the prefix as a parameter, never a hardcodedrls.module constant, so more than one registry can share a process. - No framework dependency. No
starlette/FastAPI import anywhere in the library;RlsSessioneris framework-agnostic, wrap it in your own framework's DI as needed. FORCE ROW LEVEL SECURITYon every path. Upstream only forced row security on its directcreate_policies()path, never through Alembic; withoutFORCE, the table's own owning role still bypasses every policy. This fork emits it everywhere.- One command per policy, never
FOR ALL. AFOR ALLpolicy'sUSINGclause is also OR-ed intoSELECTvisibility by Postgres, letting a write predicate leak into read visibility. - No default bypass escape. Upstream's
Policy.allow_bypass_rls=Truestitched a matchingORbranch into every generated policy whether wanted or not.rls.bypass_clauseis available but opt-in, a policy composes it explicitly. sqlglot-based clause comparison. The Alembic comparator parses both the compiled policy expression and the catalog's deparsedqual/with_checkinto ASTs (viasqlglot), folds casts, parens,= ANY (ARRAY[...])vsIN (...), and self-table qualification, then compares the canonicalized SQL text, replacing a regex-based text fold.ops/as a folder, not one file:operations.py(theMigrateOperationclasses),implementations.py(their DDL),comparator.py(the autogenerate diff),renderer.py(turning a queued op back into migration source).- Every op is self-contained.
ApplyRlsOp/DropRlsOp/CreatePolicyOp/DropPolicyOpall carry their compiled policies inline; nothing in the Alembic layer reads a global metadata reference at migration-run time. - Pydantic models.
Policy,CompiledPolicy,RlsSessioner, andAsyncRlsSessionerare frozenpydantic.BaseModels rather than dataclasses or plain classes. verify_rls. New: checks the live catalog against the declared registry directly, independent of any pending migration, for a CI gate or a startup guard.security_invoker_view. New: documents and codifies theWITH (security_invoker = true)rule a view over a protected table must follow.
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 Distributions
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 rlsalchemy-0.4.1-py3-none-any.whl.
File metadata
- Download URL: rlsalchemy-0.4.1-py3-none-any.whl
- Upload date:
- Size: 35.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
59c8052fba250609c55e0d5683ca7d34a5b79dfa8ac0c55dccd3644b9f0c9650
|
|
| MD5 |
4c39b1742dc1e00eff23b0b3bb3dd6c0
|
|
| BLAKE2b-256 |
a0ccc30b43d5cf5b53afbd623eb5a33424998e3d3945246181255cd062d1e516
|
Provenance
The following attestation bundles were made for rlsalchemy-0.4.1-py3-none-any.whl:
Publisher:
publish.yml on phvv-me/rls
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
rlsalchemy-0.4.1-py3-none-any.whl -
Subject digest:
59c8052fba250609c55e0d5683ca7d34a5b79dfa8ac0c55dccd3644b9f0c9650 - Sigstore transparency entry: 2133723633
- Sigstore integration time:
-
Permalink:
phvv-me/rls@35d014aa06c124c7e88d1917c0c88f4422f9861e -
Branch / Tag:
refs/heads/main - Owner: https://github.com/phvv-me
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@35d014aa06c124c7e88d1917c0c88f4422f9861e -
Trigger Event:
workflow_dispatch
-
Statement type: