🚀 New: chi (χ) — an open-source autoresearch harness for fleets of LLM coding agents. Read the announcement.

flask-rls

Row-level security for Flask & SQLAlchemy on PostgreSQL — transaction-scoped tenant context, fail-closed by default.

Stack
Flask · SQLAlchemy · PostgreSQL
Status
active
Source
https://github.com/kdpisda/flask-rls
Website
https://flask-rls.com

The Flask/SQLAlchemy sibling of django-rls. Same idea — PostgreSQL row-level security instead of .filter() discipline — rebuilt on SQLAlchemy Core and the Flask request lifecycle. The port was not a find-and-replace: most of the design work went into the connection pool, where a stale session variable on a recycled connection is exactly the kind of leak RLS exists to prevent.

How it works

Policies register against tables and compile to real CREATE POLICY DDL. Tenant- and user-scoped policies come ready-made, and ExpressionPolicy composes predicates from plain SQLAlchemy Core expressions:

from flask_rls import TenantPolicy, ExpressionPolicy, RLS
from sqlalchemy import column, true

rls.register("invoices", TenantPolicy("tenant_isolation", "tenant_id"))

rls.register(
    "projects",
    ExpressionPolicy(
        "project_access",
        expr=(column("owner_id") == RLS.user_id()) | (column("is_public") == true()),
    ),
)

At each transaction begin, the extension reads g.tenant_id / g.user_id and issues SELECT set_config('rls.tenant_id', ..., true). No context set → the variable is NULL → policies match zero rows: fail closed, not fail open. Outside a request (jobs, CLI, tests), rls.override(tenant_id=42) switches identity explicitly and rls.bypass() emits no context at all — and is deliberately not god-mode: true cross-tenant access requires a PostgreSQL BYPASSRLS role.

Under the hood

  • Context is set with is_local=true, so it dies with the transaction — it cannot survive onto a pooled connection and leak into the next request. This is a deliberate divergence from django-rls’s session-scoped context.
  • FORCE ROW LEVEL SECURITY is emitted for every registered table, so the table owner is constrained too — the classic owner-bypass gotcha.
  • Alembic operations (op.enable_rls, op.force_rls, op.create_policy, …) ship in an optional extra, and flask rls sql dumps the full policy DDL for review or manual application.
  • ORM-agnostic: works with bare SQLAlchemy or Flask-SQLAlchemy; config keys (RLS_GUC_PREFIX, RLS_REQUIRE_CONTEXT, RLS_DEBUG, …) tune the strictness.

Runs on Python 3.10+, SQLAlchemy 2.0+, Flask 2.2+, and PostgreSQL 12+. Published on PyPI as flask-rls (BSD-3-Clause), with docs at flask-rls.com. Full story in the deep dive.