API Reference¶
Auto-generated from source code docstrings.
Top-Level Exports¶
The most common symbols are available directly from django_rls_tenants:
from django_rls_tenants import (
AddM2MRLSPolicy,
RLSConstraint,
RLSM2MConstraint,
RLSManager,
RLSProtectedModel,
RLSTenantMiddleware,
RLSTenantModelAdmin,
TenantQuerySet,
TenantUser,
admin_context,
async_admin_context,
async_tenant_context,
current_tenant_value_sql,
safe_tenant_sql,
tenant_context,
with_rls_context,
)
Removed from top-level in v1.2.1
Raw state functions (get_current_tenant_id, set_current_tenant_id,
reset_current_tenant_id, get_rls_context_active, set_rls_context_active,
reset_rls_context_active) and exception classes (NoTenantContextError,
RLSConfigurationError, RLSTenantError) are no longer re-exported from the
top-level package. Import them from their actual modules instead:
Exceptions¶
Custom exception hierarchy for precise error handling. All exceptions live in
django_rls_tenants.exceptions. Import them from that module directly.
django_rls_tenants.exceptions.RLSTenantError
¶
Bases: Exception
Base exception for all django-rls-tenants errors.
Catch this to handle any error raised by the library.
Accepts an optional, keyword-only hint describing how to fix the
error. When supplied, it is appended to str(exc) after a blank line
and a Hint: label, so the suggestion shows up in tracebacks and
logs. The bare message and the hint are also exposed as
attributes for programmatic access.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
str
|
Human-readable description of what went wrong. |
required |
hint
|
str | None
|
Optional actionable suggestion for fixing the error. |
None
|
Attributes:
| Name | Type | Description |
|---|---|---|
message |
The error description, without the hint. |
|
hint |
The actionable suggestion, or |
Source code in django_rls_tenants/exceptions.py
django_rls_tenants.exceptions.NoTenantContextError
¶
Bases: RLSTenantError
Query or context operation attempted without an active tenant context.
Raised when STRICT_MODE=True and a queryset evaluation is attempted
without an active tenant_context(), admin_context(),
for_user(), or RLSTenantMiddleware context.
Also raised by tenant_context() and _resolve_user_guc_vars()
when a non-admin user has rls_tenant_id=None.
Source code in django_rls_tenants/exceptions.py
django_rls_tenants.exceptions.RLSConfigurationError
¶
Bases: RLSTenantError
Invalid or missing RLS configuration.
Raised when a required configuration key is missing from
settings.RLS_TENANTS or when a configuration value is invalid.
Source code in django_rls_tenants/exceptions.py
RLS Layer¶
Generic PostgreSQL Row-Level Security primitives. This layer has zero imports
from tenants/.
GUC Helpers¶
django_rls_tenants.rls.guc.set_guc
¶
Set a PostgreSQL session variable (GUC).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Variable name (e.g., |
required |
value
|
str
|
Variable value as string. |
required |
is_local
|
bool
|
If |
False
|
using
|
str
|
Database alias. Default: |
'default'
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
RuntimeError
|
If |
Source code in django_rls_tenants/rls/guc.py
django_rls_tenants.rls.guc.get_guc
¶
Get a PostgreSQL session variable value.
Returns:
| Type | Description |
|---|---|
str | None
|
The variable value, or |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Source code in django_rls_tenants/rls/guc.py
django_rls_tenants.rls.guc.clear_guc
¶
Clear a GUC variable by setting it to an empty string.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Variable name to clear. |
required |
is_local
|
bool
|
If |
False
|
using
|
str
|
Database alias. Default: |
'default'
|
Source code in django_rls_tenants/rls/guc.py
django_rls_tenants.rls.guc.restore_guc
¶
restore_guc(
name: str,
previous: str | None,
*,
is_local: bool = False,
using: str = "default",
) -> None
Put a GUC back to a value previously read with :func:get_guc.
None (unset or empty before) clears the variable. is_local must
match the scope the value was changed with: a SET LOCAL change is
undone with another SET LOCAL so the outer transaction-local value
is reinstated, while a session-level change is undone with set_config.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Variable name to restore. |
required |
previous
|
str | None
|
Value returned by |
required |
is_local
|
bool
|
If |
False
|
using
|
str
|
Database alias. Default: |
'default'
|
Source code in django_rls_tenants/rls/guc.py
Async GUC Helpers¶
Each runs the sync helper in one thread_sensitive sync_to_async hop. Django has no
asynchronous database cursor, so these are not non-blocking calls; see
Async Code.
django_rls_tenants.rls.guc.aset_guc
async
¶
Async counterpart of :func:set_guc.
Runs set_guc in a thread_sensitive sync_to_async hop. Django
has no asynchronous database cursor, so this is not a non-blocking call:
it executes on the thread that owns the current task's database
connection (under ASGI, the request's thread-sensitive thread, shared
with the async ORM), which is what keeps the GUC on the same connection
the queries use.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Variable name (e.g., |
required |
value
|
str
|
Variable value as string. |
required |
is_local
|
bool
|
If |
False
|
using
|
str
|
Database alias. Default: |
'default'
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
RuntimeError
|
If |
Source code in django_rls_tenants/rls/guc.py
django_rls_tenants.rls.guc.aget_guc
async
¶
Async counterpart of :func:get_guc.
Runs get_guc in a thread_sensitive sync_to_async hop on the
thread that owns the current task's database connection (see
:func:aset_guc; this is not a non-blocking call).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Variable name (e.g., |
required |
using
|
str
|
Database alias. Default: |
'default'
|
Returns:
| Type | Description |
|---|---|
str | None
|
The variable value, or |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Source code in django_rls_tenants/rls/guc.py
django_rls_tenants.rls.guc.aclear_guc
async
¶
Async counterpart of :func:clear_guc.
Runs clear_guc in a thread_sensitive sync_to_async hop on the
thread that owns the current task's database connection (see
:func:aset_guc; this is not a non-blocking call).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Variable name to clear. |
required |
is_local
|
bool
|
If |
False
|
using
|
str
|
Database alias. Default: |
'default'
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
RuntimeError
|
If |
Source code in django_rls_tenants/rls/guc.py
django_rls_tenants.rls.guc.arestore_guc
async
¶
arestore_guc(
name: str,
previous: str | None,
*,
is_local: bool = False,
using: str = "default",
) -> None
Async counterpart of :func:restore_guc.
Runs restore_guc in a thread_sensitive sync_to_async hop on
the thread that owns the current task's database connection (see
:func:aset_guc; this is not a non-blocking call).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Variable name to restore. |
required |
previous
|
str | None
|
Value returned by |
required |
is_local
|
bool
|
If |
False
|
using
|
str
|
Database alias. Default: |
'default'
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
RuntimeError
|
If |
Source code in django_rls_tenants/rls/guc.py
Constraints¶
django_rls_tenants.rls.constraints.RLSConstraint
¶
RLSConstraint(
*,
field: str,
name: str,
guc_tenant_var: str = "rls.current_tenant",
guc_admin_var: str = "rls.is_admin",
tenant_pk_type: str = "int",
extra_bypass_flags: list[str] | None = None,
)
Bases: BaseConstraint
Django constraint that generates PostgreSQL RLS policies during migrations.
When Django applies a migration containing this constraint, it:
- Enables RLS on the table (
ALTER TABLE ... ENABLE ROW LEVEL SECURITY). - Forces RLS for the table owner (
ALTER TABLE ... FORCE ROW LEVEL SECURITY). - Creates an isolation policy with configurable
USINGandWITH CHECK.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
field
|
str
|
FK field name for tenant identification (e.g., |
required |
name
|
str
|
Constraint name. Supports |
required |
guc_tenant_var
|
str
|
GUC variable holding the current tenant ID.
Default: |
'rls.current_tenant'
|
guc_admin_var
|
str
|
GUC variable for admin bypass.
Default: |
'rls.is_admin'
|
tenant_pk_type
|
str
|
SQL cast type for tenant PK.
Default: |
'int'
|
extra_bypass_flags
|
list[str] | None
|
Additional GUC variables that bypass the |
None
|
Source code in django_rls_tenants/rls/constraints.py
constraint_sql
¶
No inline constraint SQL; defer RLS DDL to after CREATE TABLE.
Django calls constraint_sql during CREATE TABLE for inline
constraints. RLS policies require the table to exist first, so we
defer the actual DDL and return an empty string (filtered out by
Django's if statement guard).
Source code in django_rls_tenants/rls/constraints.py
create_sql
¶
create_sql(
model: type[Model] | None,
schema_editor: BaseDatabaseSchemaEditor | None,
) -> Statement
Generate SQL to enable RLS and create the isolation policy.
Source code in django_rls_tenants/rls/constraints.py
remove_sql
¶
remove_sql(
model: type[Model] | None,
schema_editor: BaseDatabaseSchemaEditor | None,
) -> Statement
Generate SQL to drop the policy and disable RLS.
Source code in django_rls_tenants/rls/constraints.py
validate
¶
validate(
model: type[Model],
instance: Any,
exclude: Any = None,
using: str | None = None,
) -> None
No-op: RLS is enforced at the database level, not in Django validation.
deconstruct
¶
Return a 3-tuple for Django's migration serializer.
Source code in django_rls_tenants/rls/constraints.py
django_rls_tenants.rls.constraints.RLSM2MConstraint
¶
RLSM2MConstraint(
*,
name: str,
from_model: str,
to_model: str,
from_fk: str,
to_fk: str,
from_tenant_fk: str | None = "tenant",
to_tenant_fk: str | None = "tenant",
guc_tenant_var: str = "rls.current_tenant",
guc_admin_var: str = "rls.is_admin",
tenant_pk_type: str = "int",
)
Bases: BaseConstraint
Constraint generating subquery-based RLS policies for M2M join tables.
Unlike RLSConstraint which checks a direct {field}_id column,
this generates policies that verify both FK references point to rows
belonging to the current tenant via EXISTS (SELECT 1 ...) subqueries.
EXISTS is used rather than IN (SELECT ...) because it gives the
planner more optimisation freedom; see _build_m2m_conditions.
For join tables where only one side is RLS-protected, only that side's FK is checked. For both sides protected, both FKs are checked.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Constraint name. |
required |
from_model
|
str
|
Dotted path to the "from" model (e.g., |
required |
to_model
|
str
|
Dotted path to the "to" model (e.g., |
required |
from_fk
|
str
|
FK column name for the "from" side (e.g., |
required |
to_fk
|
str
|
FK column name for the "to" side (e.g., |
required |
from_tenant_fk
|
str | None
|
Tenant FK field name on the "from" model,
or |
'tenant'
|
to_tenant_fk
|
str | None
|
Tenant FK field name on the "to" model,
or |
'tenant'
|
guc_tenant_var
|
str
|
GUC variable for current tenant.
Default: |
'rls.current_tenant'
|
guc_admin_var
|
str
|
GUC variable for admin bypass.
Default: |
'rls.is_admin'
|
tenant_pk_type
|
str
|
SQL cast type for tenant PK.
Default: |
'int'
|
Source code in django_rls_tenants/rls/constraints.py
constraint_sql
¶
No inline constraint SQL; defer RLS DDL to after CREATE TABLE.
Source code in django_rls_tenants/rls/constraints.py
create_sql
¶
create_sql(
model: type[Model] | None,
schema_editor: BaseDatabaseSchemaEditor | None,
) -> Statement
Generate SQL to enable RLS and create the M2M isolation policy.
Source code in django_rls_tenants/rls/constraints.py
remove_sql
¶
remove_sql(
model: type[Model] | None,
schema_editor: BaseDatabaseSchemaEditor | None,
) -> Statement
Generate SQL to drop the M2M policy and disable RLS.
Source code in django_rls_tenants/rls/constraints.py
validate
¶
validate(
model: type[Model],
instance: Any,
exclude: Any = None,
using: str | None = None,
) -> None
No-op: RLS is enforced at the database level, not in Django validation.
deconstruct
¶
Return a 3-tuple for Django's migration serializer.
Source code in django_rls_tenants/rls/constraints.py
Migration Operations¶
django_rls_tenants.operations.AddM2MRLSPolicy
¶
AddM2MRLSPolicy(
m2m_table: str,
from_model: str,
to_model: str,
from_fk: str,
to_fk: str,
from_tenant_fk: str | None = "tenant",
to_tenant_fk: str | None = "tenant",
guc_tenant_var: str = "rls.current_tenant",
guc_admin_var: str = "rls.is_admin",
tenant_pk_type: str = "int",
)
Bases: Operation
Migration operation to add an RLS policy to an M2M through table.
Generates and executes subquery-based CREATE POLICY SQL that
checks both FK sides of the M2M join table belong to the current
tenant. Supports tables where only one side is RLS-protected.
This operation is reversible: database_backwards drops the policy
and disables RLS on the table.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
m2m_table
|
str
|
The database table name of the M2M through table. |
required |
from_model
|
str
|
Dotted path to the "from" model (e.g., |
required |
to_model
|
str
|
Dotted path to the "to" model (e.g., |
required |
from_fk
|
str
|
FK column on the through table for the "from" side. |
required |
to_fk
|
str
|
FK column on the through table for the "to" side. |
required |
from_tenant_fk
|
str | None
|
Tenant FK on the "from" model, or |
'tenant'
|
to_tenant_fk
|
str | None
|
Tenant FK on the "to" model, or |
'tenant'
|
guc_tenant_var
|
str
|
GUC variable for current tenant.
Default: |
'rls.current_tenant'
|
guc_admin_var
|
str
|
GUC variable for admin bypass.
Default: |
'rls.is_admin'
|
tenant_pk_type
|
str
|
SQL cast type for tenant PK.
Default: |
'int'
|
Source code in django_rls_tenants/operations.py
state_forwards
¶
database_forwards
¶
database_forwards(
app_label: str,
schema_editor: BaseDatabaseSchemaEditor,
from_state: ProjectState,
to_state: ProjectState,
) -> None
Create the M2M RLS policy.
Source code in django_rls_tenants/operations.py
database_backwards
¶
database_backwards(
app_label: str,
schema_editor: BaseDatabaseSchemaEditor,
from_state: ProjectState,
to_state: ProjectState,
) -> None
Drop the M2M RLS policy and disable RLS.
Source code in django_rls_tenants/operations.py
describe
¶
deconstruct
¶
Return args for Django's migration serializer.
Source code in django_rls_tenants/operations.py
Context Managers¶
django_rls_tenants.rls.context.rls_context
¶
rls_context(
variables: dict[str, str],
*,
is_local: bool = False,
using: str = "default",
) -> Iterator[None]
Set multiple GUC variables for the duration of a block.
Saves and restores previous values on exit (supports nesting) in both
GUC modes. With is_local=True the previous values are re-issued with
SET LOCAL so an enclosing block keeps its scope until the
transaction ends.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
variables
|
dict[str, str]
|
Dict of GUC variable names to values. |
required |
is_local
|
bool
|
If |
False
|
using
|
str
|
Database alias. Default: |
'default'
|
Source code in django_rls_tenants/rls/context.py
django_rls_tenants.rls.context.bypass_flag
¶
Temporarily set a GUC bypass flag to 'true'.
Saves and restores the previous value on exit (supports nesting) in both GUC modes.
Usage::
with bypass_flag("rls.is_login_request"):
user = User.objects.get(email=email)
Source code in django_rls_tenants/rls/context.py
django_rls_tenants.rls.context.async_rls_context
async
¶
async_rls_context(
variables: dict[str, str],
*,
is_local: bool = False,
using: str = "default",
) -> AsyncIterator[None]
Async counterpart of :func:rls_context, for async with.
Snapshots and sets the variables in one thread_sensitive
sync_to_async hop on entry and restores them in one hop on exit (see
:func:django_rls_tenants.rls.guc.aset_guc for what a hop is and why it
is not a non-blocking call). Nesting, the restore in both GUC modes and
the aborted-transaction behaviour are the same as for :func:rls_context.
The restore runs even when the task is cancelled while entering or inside the block. Only a second cancellation delivered during the restore itself can skip it; wrap long-lived tasks in your own cleanup if that matters.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
variables
|
dict[str, str]
|
Dict of GUC variable names to values. |
required |
is_local
|
bool
|
If |
False
|
using
|
str
|
Database alias. Default: |
'default'
|
Source code in django_rls_tenants/rls/context.py
django_rls_tenants.rls.context.async_bypass_flag
async
¶
async_bypass_flag(
flag_name: str,
*,
is_local: bool = False,
using: str = "default",
) -> AsyncIterator[None]
Async counterpart of :func:bypass_flag, for async with.
Sets the flag to 'true' in one thread_sensitive hop and restores
the previous value in one hop on exit, with the same semantics as
:func:async_rls_context.
Usage::
async with async_bypass_flag("rls.is_login_request"):
user = await User.objects.aget(email=email)
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
flag_name
|
str
|
GUC variable name (e.g., |
required |
is_local
|
bool
|
If |
False
|
using
|
str
|
Database alias. Default: |
'default'
|
Source code in django_rls_tenants/rls/context.py
Tenants Layer¶
Django multitenancy built on top of the rls/ primitives.
Configuration¶
django_rls_tenants.tenants.conf.RLSTenantsConfig
¶
Read library configuration from settings.RLS_TENANTS.
All settings live under a single dict::
RLS_TENANTS = {
"TENANT_MODEL": "myapp.Tenant", # Required
"DATABASES": ["default"], # Default: ["default"]
"GUC_PREFIX": "rls", # Default: "rls"
"STRICT_MODE": False, # Default: False
"TENANT_FK_FIELD": "tenant", # Default: "tenant"
"USER_PARAM_NAME": "as_user", # Default: "as_user"
"TENANT_PK_TYPE": "int", # Default: "int"
"USE_LOCAL_SET": False, # Default: False
}
Source code in django_rls_tenants/tenants/conf.py
TENANT_FK_FIELD
property
¶
FK field name on RLSProtectedModel. Default: "tenant".
USER_PARAM_NAME
property
¶
Parameter name @with_rls_context looks for. Default: "as_user".
USE_LOCAL_SET
property
¶
Set the GUCs transaction-locally (SET LOCAL). Default: False.
The GUC affinity guard then sets the active context's values in each transaction, giving a statement run in autocommit inside a context a transaction of its own.
STRICT_MODE
property
¶
Raise on queries without tenant context. Default: False.
When enabled, TenantQuerySet evaluation methods raise
NoTenantContextError if no RLS context is active (no
tenant_context(), admin_context(), for_user(),
or RLSTenantMiddleware).
DATABASES
property
¶
Database aliases that follow the active RLS context. Default: ["default"].
The middleware sets the GUCs on each of them (with
USE_LOCAL_SET=True on its synchronous path only), and the GUC
affinity guard applies the active context on each of them before
every statement, whichever alias the context was entered on
(transaction-locally with USE_LOCAL_SET=True). With
USE_LOCAL_SET=True an alias outside this list still needs
transaction.atomic() around a context entered on it.
In multi-database setups (e.g., read replicas), add all aliases
that serve RLS-protected queries::
RLS_TENANTS = {
"DATABASES": ["default", "replica"],
}
reload
¶
Discard the cached RLS_TENANTS dict so the next read hits settings.
The app config connects this to Django's setting_changed signal, so
override_settings(RLS_TENANTS=...), @override_settings on a test
class and pytest-django's settings fixture all take effect without
touching the cache by hand. The unknown-key check runs again on the
next read as well, so a typo introduced by an override is still reported.
Source code in django_rls_tenants/tenants/conf.py
Models¶
django_rls_tenants.tenants.models.RLSProtectedModel
¶
Bases: Model
Abstract base model for tenant-scoped models.
Provides:
- A
tenantForeignKey added dynamically via theclass_preparedsignal (target read fromRLS_TENANTS["TENANT_MODEL"]). RLSManageras the default manager (withfor_user()).RLSConstraintinMeta.constraints(generates RLS policy).
Usage::
class Order(RLSProtectedModel):
product = models.CharField(max_length=255)
amount = models.DecimalField(...)
class Meta(RLSProtectedModel.Meta):
db_table = "order"
To customize the tenant FK (e.g., nullable for admin users),
declare the field directly on your model -- the class_prepared
handler will not add a duplicate::
class User(AbstractUser, RLSProtectedModel):
tenant = models.ForeignKey(
Tenant, on_delete=models.CASCADE,
null=True, blank=True,
)
Managers¶
django_rls_tenants.tenants.managers.TenantQuerySet
¶
Bases: QuerySet
QuerySet that applies a for_user() user's RLS context when it runs SQL.
for_user() stores the user and sets nothing. Every method that
executes SQL then runs its statements in a scope that sets the user's
GUC variables and tenant state on the connection the SQL uses and puts
the previous values back afterwards (see "Lazy QuerySet Evaluation" in
the architecture guide). That covers
iteration (_fetch_all()), count(), exists(),
aggregate(), iterator(), aiterator(), update(),
delete(), create(), bulk_create(), bulk_update(),
update_or_create() and explain(); everything else (get(),
first(), get_or_create(), contains(), in_bulk(), the
a-prefixed async methods, ...) runs its SQL through one of those.
When auto-scope is active (via tenant_context() or middleware),
select_related() automatically adds WHERE related.tenant_id = X
for joined RLS-protected tables, enabling index usage on both sides
of the join.
Source code in django_rls_tenants/tenants/managers.py
for_user
¶
Scope this queryset to the given user's tenant.
For admin users: returns all rows (RLS admin bypass at eval time). For tenant users: returns rows matching the user's tenant.
The queryset remains lazy and chainable. The user's GUC variables
are set when the queryset runs SQL, not when for_user() is
called, and only for the duration of that SQL: every method that
executes a query applies them, and the values found on the
connection before are restored afterwards (see "Lazy QuerySet
Evaluation" in the architecture guide).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
as_user
|
TenantUser
|
User object satisfying the |
required |
Source code in django_rls_tenants/tenants/managers.py
select_related
¶
Override to add tenant filters on joined RLS-protected tables.
When a tenant context is active (or for_user() was called),
adds WHERE related.tenant_id = X for each explicitly named
relation that targets an RLS-protected model. This enables
PostgreSQL to use composite indexes on joined tables instead of
relying solely on per-row RLS current_setting() evaluation.
Falls back to super().select_related() when no tenant scope
is active. Called with no arguments (select-all mode) it adds no
tenant filters; Django 6.1 deprecates that form, and the
RemovedInDjango2028Warning names the caller's line, not this
method.
select_related(None) clears the selection without adding
tenant filters. select_related(False) is treated the same way
as a convenience kept for existing callers; Django itself has
never accepted False.
Source code in django_rls_tenants/tenants/managers.py
count
¶
Run count() in the for_user() scope; guarded by strict mode.
A queryset with cached results answers from the cache, so it runs no SQL and needs neither the scope nor the strict mode check.
Source code in django_rls_tenants/tenants/managers.py
exists
¶
Run exists() in the for_user() scope; guarded by strict mode.
A queryset with cached results answers from the cache, so it runs no SQL and needs neither the scope nor the strict mode check.
Source code in django_rls_tenants/tenants/managers.py
aggregate
¶
Run aggregate() in the for_user() scope; guarded by strict mode.
Source code in django_rls_tenants/tenants/managers.py
update
¶
Run update() in the for_user() scope; guarded by strict mode.
delete
¶
Run delete() in the for_user() scope; guarded by strict mode.
One scope covers the whole deletion: collecting the rows, the
cascades and SET_NULL updates on related models, the delete
signals and the DELETE statements.
Source code in django_rls_tenants/tenants/managers.py
iterator
¶
Run iterator() in the for_user() scope; guarded by strict mode.
Each chunk of chunk_size rows (Django's default of 2000 when
None) is fetched, together with its prefetch_related()
lookups, inside its own scope. The scope is not held while the
caller's loop body runs, so the user's context neither leaks into
that code nor stays on the connection when the loop stops early.
Source code in django_rls_tenants/tenants/managers.py
aiterator
async
¶
Run aiterator() in the for_user() scope; guarded by strict mode.
Django's aiterator() does not go through iterator(). With a
for_user() user, each chunk is pulled from :meth:iterator in
one thread-sensitive sync_to_async hop, so every scope is
entered and left inside a hop, on the thread that owns the
connection.
Source code in django_rls_tenants/tenants/managers.py
create
¶
Run create() in the for_user() scope.
The INSERT must pass the RLS policy's WITH CHECK clause,
which reads the GUCs. Unlike bulk_create(), which has been
guarded since strict mode was added, create() is not: without a
context the database rejects the row loudly instead of returning a
wrong result, and guarding it now would break code that creates
rows outside a context on a superuser connection.
Source code in django_rls_tenants/tenants/managers.py
bulk_create
¶
bulk_create(
objs: Any,
batch_size: int | None = None,
ignore_conflicts: bool = False,
update_conflicts: bool = False,
update_fields: Any = None,
unique_fields: Any = None,
) -> list[Any]
Run bulk_create() in the for_user() scope; guarded by strict mode.
Source code in django_rls_tenants/tenants/managers.py
bulk_update
¶
Run bulk_update() in one for_user() scope; guarded by strict mode.
Source code in django_rls_tenants/tenants/managers.py
update_or_create
¶
update_or_create(
defaults: Mapping[str, Any] | None = None,
create_defaults: Mapping[str, Any] | None = None,
**kwargs: Any,
) -> tuple[Any, bool]
Run update_or_create() in the for_user() scope.
Updating an existing row calls Model.save(), which writes
through the model's base manager rather than this queryset, so one
scope spans the whole call. The lookup runs through _fetch_all()
and is guarded by strict mode there.
Source code in django_rls_tenants/tenants/managers.py
explain
¶
Run explain() in the for_user() scope.
With analyze=True the query runs, and the plan then reports the
user's rows. Not guarded by strict mode: it is a diagnostic.
Source code in django_rls_tenants/tenants/managers.py
get
¶
first
¶
Guard first() with strict mode check, unless the results are cached.
On a cached, ordered queryset Django answers from the cache. Any
other case queries a clone, which _fetch_all() checks.
Source code in django_rls_tenants/tenants/managers.py
last
¶
Guard last() with strict mode check.
It always queries: Django reverses the ordering on a fresh clone.
django_rls_tenants.tenants.managers.RLSManager
¶
Bases: Manager
Manager for RLS-protected models.
Provides for_user() for scoped queries and
prepare_tenant_in_model_data() for resolving tenant FKs.
get_queryset
¶
Return a TenantQuerySet instance, auto-scoped if a tenant context is active.
When tenant_context(), admin_context(), or RLSTenantMiddleware
has set a current tenant ID, the queryset is automatically filtered by
WHERE tenant_id = X. This enables PostgreSQL to use composite indexes
instead of relying solely on RLS current_setting() calls.
Source code in django_rls_tenants/tenants/managers.py
for_user
¶
prepare_tenant_in_model_data
¶
Resolve a raw tenant ID for model creation.
If model_data contains a raw tenant ID (int/str) under
the configured FK field name, sets the FK column directly
({field}_id) to avoid a SELECT query. Allows passing
tenant=42 in creation data without N+1 overhead.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model_data
|
dict[str, Any]
|
Dict of field names to values. |
required |
as_user
|
TenantUser
|
User for context (unused here but part of API). |
required |
Source code in django_rls_tenants/tenants/managers.py
Context Managers¶
django_rls_tenants.tenants.context.tenant_context
¶
Set RLS context to a specific tenant. Supports nesting.
The previous GUC values are saved on entry and restored on exit in both
GUC modes. With USE_LOCAL_SET=True the restore is issued with
SET LOCAL so an enclosing block keeps its database scope for the
rest of the transaction (see :func:django_rls_tenants.rls.context.restore_gucs
for the behaviour when the block exits with an aborted transaction).
Outside transaction.atomic() that mode writes nothing on entry or
exit: the GUC affinity guard sets the context's values
transaction-locally for each statement, giving a statement run in
autocommit a transaction of its own (see "GUC affinity guard" in the
architecture guide).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
tenant_id
|
int | str
|
The tenant PK to scope queries to. |
required |
using
|
str
|
Database alias the GUCs are written to on entry and restored on
exit. Default: |
'default'
|
Raises:
| Type | Description |
|---|---|
NoTenantContextError
|
If |
RuntimeError
|
If |
Source code in django_rls_tenants/tenants/context.py
django_rls_tenants.tenants.context.admin_context
¶
Set RLS context to admin mode. Supports nesting.
The previous GUC values are saved on entry and restored on exit in both
GUC modes, so an inner admin_context() never leaks the admin bypass
into the enclosing block (see :func:tenant_context, also for
USE_LOCAL_SET=True outside transaction.atomic()).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
using
|
str
|
Database alias the GUCs are written to on entry and restored on
exit. Default: |
'default'
|
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If |
Source code in django_rls_tenants/tenants/context.py
django_rls_tenants.tenants.context.async_tenant_context
async
¶
Async counterpart of :func:tenant_context, for async with. Supports nesting.
Usage::
async with async_tenant_context(tenant.pk):
count = await Order.objects.acount()
The GUCs are snapshotted and set in one thread_sensitive
sync_to_async hop on entry and restored in one hop on exit. Django
has no asynchronous database cursor, so this is not a non-blocking call;
the hop runs on the thread that owns the task's connection (under ASGI,
the request's thread, shared with the async ORM). The tenant state
ContextVar values are set and reset in the coroutine itself, so the
ORM auto-scope filter and STRICT_MODE see them in async code. Nesting
(with sync or async blocks) and the restore in both GUC modes behave as
in :func:tenant_context.
USE_LOCAL_SET. A coroutine cannot open transaction.atomic(), so
with USE_LOCAL_SET=True the hop thread is usually in autocommit: the
block then writes nothing, and the GUC affinity guard sets its values
transaction-locally for each statement, in a transaction of its own (see
:func:tenant_context). When the coroutine is driven by
async_to_sync from synchronous code inside atomic(), the block
writes SET LOCAL values into that transaction, as the sync manager
does. On an alias outside RLS_TENANTS["DATABASES"], which the guard
leaves alone, it needs that transaction and raises
:class:RLSConfigurationError without one.
Concurrent tasks. Sibling tasks of one request (for example under
asyncio.gather) share the request's database connection, but each
has its own ContextVar state. The GUC affinity guard applies the
context of the task that runs a statement before that statement, so
blocks for different tenants can run concurrently, raw SQL,
safe_tenant_sql() and M2M through tables included. It does not cover
bypass flags and other GUCs set with async_bypass_flag() /
async_rls_context() (sibling tasks see them), or SQL that bypasses
Django's cursor. Work that outlives the block (a task it started without
awaiting it) runs without its context.
The restore runs even when the task is cancelled while entering or
inside the block; only a second cancellation delivered during the
restore itself can skip it (for requests RLSTenantMiddleware set a
context for, its safety net clears the GUCs at the end of the request).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
tenant_id
|
int | str
|
The tenant PK to scope queries to. |
required |
using
|
str
|
Database alias the GUCs are written to first, as in
:func: |
'default'
|
Raises:
| Type | Description |
|---|---|
NoTenantContextError
|
If |
RLSConfigurationError
|
If |
Source code in django_rls_tenants/tenants/context.py
461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 | |
django_rls_tenants.tenants.context.async_admin_context
async
¶
Async counterpart of :func:admin_context, for async with. Supports nesting.
Same hop model, USE_LOCAL_SET behaviour and concurrent-task
behaviour as :func:async_tenant_context. An inner
async_admin_context() never leaks the admin bypass into the
enclosing block.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
using
|
str
|
Database alias the GUCs are written to first, as in
:func: |
'default'
|
Raises:
| Type | Description |
|---|---|
RLSConfigurationError
|
If |
Source code in django_rls_tenants/tenants/context.py
django_rls_tenants.tenants.context.with_rls_context
¶
with_rls_context(
func: Callable[P, R] | None = None,
*,
user_param: str | None = None,
) -> (
Callable[P, R]
| Callable[[Callable[P, R]], Callable[P, R]]
)
Decorator that extracts a user argument and sets RLS context.
Can be used bare or with an explicit user_param::
@with_rls_context
def my_view(request, as_user): ...
@with_rls_context(user_param="current_user")
def my_view(request, current_user): ...
async def functions are supported: the decorator detects them with
:func:inspect.iscoroutinefunction and returns an async def wrapper
that enters :func:async_tenant_context / :func:async_admin_context
around the awaited body. The wrapper is itself a coroutine function, so
Django and other async-aware decorators treat it as async::
@with_rls_context
async def order_count(as_user):
return await Order.objects.acount()
In the async wrapper the user argument is read (is_tenant_admin,
rls_tenant_id) in the coroutine, when the call is awaited, so those
properties must not query the database; pass a loaded user (in an async
view, await request.auser(), not request.user).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
func
|
Callable[P, R] | None
|
The function to decorate (when used without parentheses). |
None
|
user_param
|
str | None
|
Override the parameter name to look for. Defaults to
|
None
|
Returns:
| Type | Description |
|---|---|
Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]
|
The decorated function with the same signature as the original, |
Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]
|
or a decorator factory when called with keyword arguments. |
Raises:
| Type | Description |
|---|---|
TypeError
|
At decoration time, if the function is a generator or an async generator function (its body runs while the caller iterates, after the context has been exited). At call time, if a function decorated as synchronous returns a coroutine. |
NoTenantContextError
|
At call time (when awaited, for |
When the user argument is None, logs a warning and proceeds
without context (fail-closed: RLS blocks all access).
Source code in django_rls_tenants/tenants/context.py
717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 | |
Raw SQL¶
Helpers for scoping hand-written SQL to the current tenant. See the Raw SQL guide for usage and safety notes.
django_rls_tenants.tenants.sql.safe_tenant_sql
¶
safe_tenant_sql(
column: str = "tenant_id",
*,
table: str | None = None,
include_admin: bool = True,
extra_bypass_flags: list[str] | None = None,
) -> str
Return a WHERE-clause fragment scoping rows to the current tenant.
The fragment compares column against the current-tenant GUC, using the
exact expression the RLS policies use (via
:mod:~django_rls_tenants.rls.policy_sql). When include_admin is true
it also lets rows through while the admin-bypass GUC is set, mirroring
admin_context(). Splice it straight into a raw query::
sql = f"SELECT * FROM orders WHERE {safe_tenant_sql()} AND amount > %s"
cursor.execute(sql, [100])
There are deliberately no bind parameters: the tenant id is read inside PostgreSQL from the session GUC, so the fragment contains no Python-side user input.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
column
|
str
|
Tenant foreign-key column on the target table. Defaults to
|
'tenant_id'
|
table
|
str | None
|
Optional table name/alias to qualify the column with, e.g.
|
None
|
include_admin
|
bool
|
When |
True
|
extra_bypass_flags
|
list[str] | None
|
Additional boolean bypass GUCs that should also let
rows through, matching the |
None
|
Returns:
| Type | Description |
|---|---|
str
|
A SQL |
str
|
wrapped in parentheses ( |
str
|
composes safely with surrounding |
str
|
|
Warning
The parentheses only make the fragment safe to combine with AND.
Appending OR defeats tenant isolation -- WHERE {safe_tenant_sql()}
OR is_public returns rows from every tenant. Always restrict
further with AND, never OR.
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Example
safe_tenant_sql("tenant_id", include_admin=False) "tenant_id = nullif((SELECT current_setting('rls.current_tenant', true)), '')::int"
The default (include_admin=True) wraps that predicate as
(<predicate> OR (SELECT current_setting('rls.is_admin', true)) = 'true').
Source code in django_rls_tenants/tenants/sql.py
70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 | |
django_rls_tenants.tenants.sql.current_tenant_value_sql
¶
Return the current-tenant value expression for use in raw SQL.
Produces the same cast GUC read the RLS policies compare against -- e.g.
nullif((SELECT current_setting('rls.current_tenant', true)), '')::int --
suitable for an INSERT value list or a SELECT projection::
cursor.execute(
f"INSERT INTO orders (product, tenant_id) VALUES (%s, {current_tenant_value_sql()})",
["Widget"],
)
An unset GUC evaluates to NULL (via nullif(..., '')), so the cast
never fails on an empty string.
Returns:
| Type | Description |
|---|---|
str
|
A SQL value expression yielding the current tenant id (or |
str
|
no tenant context is active). |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the configured GUC name or tenant PK type is invalid. |
Example
current_tenant_value_sql() "nullif((SELECT current_setting('rls.current_tenant', true)), '')::int"
Source code in django_rls_tenants/tenants/sql.py
State¶
Internal helpers
State functions are internal helpers for custom middleware and advanced use cases.
Prefer tenant_context() and admin_context() for managing RLS state.
Import state functions from django_rls_tenants.tenants.state, not the top-level package.
Tenant ID¶
django_rls_tenants.tenants.state.get_current_tenant_id
¶
Return the current tenant ID, or None if no tenant context is active.
Returns:
| Type | Description |
|---|---|
int | str | None
|
The tenant ID set by the innermost active |
int | str | None
|
middleware, or |
Source code in django_rls_tenants/tenants/state.py
django_rls_tenants.tenants.state.set_current_tenant_id
¶
Set the current tenant ID for automatic query scoping.
Returns a token that can be passed to reset_current_tenant_id()
to restore the previous value (for nesting support).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
tenant_id
|
int | str | None
|
The tenant PK, or |
required |
Returns:
| Type | Description |
|---|---|
Token[int | str | None]
|
A |
Source code in django_rls_tenants/tenants/state.py
django_rls_tenants.tenants.state.reset_current_tenant_id
¶
Restore the previous tenant ID using a token from set_current_tenant_id().
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
token
|
Token[int | str | None]
|
The token returned by the corresponding |
required |
Source code in django_rls_tenants/tenants/state.py
RLS Context Active (Strict Mode)¶
django_rls_tenants.tenants.state.get_rls_context_active
¶
Return whether an RLS context is currently active.
An RLS context is active when tenant_context(),
admin_context(), or RLSTenantMiddleware has established
a context for the current execution scope. Used by strict mode
to distinguish "no context" from "admin context".
Returns:
| Type | Description |
|---|---|
bool
|
|
Source code in django_rls_tenants/tenants/state.py
django_rls_tenants.tenants.state.set_rls_context_active
¶
Set the RLS context active flag.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
active
|
bool
|
Whether an RLS context is active. |
required |
Returns:
| Type | Description |
|---|---|
Token[bool]
|
A |
Source code in django_rls_tenants/tenants/state.py
django_rls_tenants.tenants.state.reset_rls_context_active
¶
Restore the previous RLS context active state.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
token
|
Token[bool]
|
The token returned by |
required |
Middleware¶
django_rls_tenants.tenants.middleware.RLSTenantMiddleware
¶
Bases: MiddlewareMixin
Set RLS context for each authenticated request.
For authenticated users: sets tenant_context or admin_context
based on the user's TenantUser protocol implementation.
For unauthenticated requests: no context is set. RLS policies block all access to protected tables (fail-closed).
This is API-agnostic -- works identically for REST, GraphQL, Django views, or any other request handler.
Add to MIDDLEWARE::
MIDDLEWARE = [
...
"django_rls_tenants.tenants.middleware.RLSTenantMiddleware",
]
With USE_LOCAL_SET=True the middleware owns a database transaction
for every authenticated request on the synchronous (WSGI) path:
transaction.atomic() is opened on each alias in
RLS_TENANTS["DATABASES"] around the rest of the middleware chain and
the view, so the SET LOCAL GUCs are in force for the whole request and
vanish at commit/rollback. ATOMIC_REQUESTS is not required (and is
harmless -- it nests as a savepoint).
Under ASGI, __acall__ serves the request natively instead of
MiddlewareMixin.__acall__: one thread-sensitive hop resolves the user
and sets the GUCs, the tenant ContextVar values are set and reset
with their own tokens in the request coroutine, and one hop clears the
GUCs at the end. A subclass that overrides process_request or
process_response keeps the hook-based path, so its hooks still run.
With USE_LOCAL_SET=True a request on the async path gets no
transaction (none can be held open across the awaited chain; under ASGI
with a synchronous-only middleware after this one, Django runs it on the
synchronous path, with the transaction) and the middleware writes no GUC:
the GUC affinity guard sets the request's values transaction-locally for
each statement, giving a statement run in autocommit a transaction of its
own (#96).
Source code in django_rls_tenants/tenants/middleware.py
__call__
¶
Serve the request: on the WSGI path here, on the ASGI path in __acall__.
The WSGI request holds a context holder for its duration (see
_holder_for_the_request), which a coroutine the view runs with
async_to_sync inherits.
Source code in django_rls_tenants/tenants/middleware.py
__acall__
async
¶
Serve the request on Django's async (ASGI) path.
Replaces MiddlewareMixin.__acall__, which ran process_request and
process_response in separate sync_to_async hops. Here one
thread-sensitive hop resolves request.user and sets the GUCs on every
alias. The tenant ContextVar values are then set in the request
coroutine, so the downstream chain and async views see them, and the
finally block resets them with their own tokens, which restores any
outer value. A second hop clears the GUCs, unless process_exception
already ended the context for a failed view. Cleanup also runs when the
request task is cancelled; only a second cancellation that arrives while
the clear hop is still queued can skip it, and the request_finished
safety net then retries where it can see the request's flag. As on the
WSGI path, the context ends when the chain returns, before a streaming
response body is iterated.
A subclass that overrides process_request or process_response
is served by _acall_through_sync_hooks instead, so its hooks run as
they did before this path existed.
With USE_LOCAL_SET=True the async path opens no transaction (it
cannot hold a synchronous transaction.atomic() open across the
awaited chain) and writes no GUC; the GUC affinity guard applies the
request's context to each statement, transaction-locally.
Source code in django_rls_tenants/tenants/middleware.py
process_request
¶
Set GUC variables on all configured databases.
Iterates over RLS_TENANTS["DATABASES"] and sets GUCs on each
alias. If setting GUCs on one database fails, clears all GUCs on
databases that were already set, then re-raises. Used on the
synchronous path only; __acall__ uses _enter_request_gucs.
Source code in django_rls_tenants/tenants/middleware.py
process_response
¶
Clear GUC variables and auto-scope state to prevent cross-request leaks.
Source code in django_rls_tenants/tenants/middleware.py
process_exception
¶
Record a view failure before Django converts it to an error response.
Defer set_rollback(True) until the chain returns to __call__.
Setting it here would prevent error handlers and response middleware
from querying even after a non-database exception such as Http404.
All writes, including those made by error handlers, are rolled back
when the middleware-owned transaction exits. An explicit error
response without an exception still commits.
Without a middleware-owned transaction the hook ends the request
context instead. Under ASGI, Django calls it in its own
sync_to_async hop and then builds the error response on another
thread and database connection; ending the context first keeps that
error handling fail-closed, as on WSGI. __acall__ then only resets
its tokens.
Either way, a context block the view left open (a generator suspended inside one) is ended, so the error response does not run in it. With a middleware-owned transaction the request's own context stays in force while the error response is built (the rollback is deferred, so error handlers can still query); without one the response runs with no context (fail-closed).
Source code in django_rls_tenants/tenants/middleware.py
Admin¶
Tenant-aware Django admin. See the Admin guide for usage, the tenant switcher, and the middleware interaction.
django_rls_tenants.tenants.admin.RLSTenantModelAdmin
¶
Bases: ModelAdmin
ModelAdmin mixin that scopes every admin DB operation to the RLS context.
Register it like any ModelAdmin::
from django.contrib import admin
from django_rls_tenants import RLSTenantModelAdmin
from myapp.models import Order
@admin.register(Order)
class OrderAdmin(RLSTenantModelAdmin):
list_display = ("product", "amount")
request.user must satisfy the
:class:~django_rls_tenants.tenants.types.TenantUser protocol
(is_tenant_admin / rls_tenant_id) -- the same contract the middleware
and for_user() rely on.
Attributes:
| Name | Type | Description |
|---|---|---|
rls_allow_tenant_switch |
bool
|
Give cross-tenant admins the tenant switcher.
When |
rls_session_key |
str
|
Session key holding the switcher selection. Defaults to
|
rls_tenant_query_param |
str
|
Query parameter the switcher uses. Defaults to
|
rls_deny_without_tenant |
bool
|
When |
changelist_view
¶
changelist_view(
request: HttpRequest,
extra_context: dict[str, Any] | None = None,
) -> HttpResponse
Wrap the changelist in the request's RLS context.
Source code in django_rls_tenants/tenants/admin.py
add_view
¶
add_view(
request: HttpRequest,
form_url: str = "",
extra_context: dict[str, Any] | None = None,
) -> HttpResponse
Wrap the add view in the request's RLS context.
Source code in django_rls_tenants/tenants/admin.py
change_view
¶
change_view(
request: HttpRequest,
object_id: str,
form_url: str = "",
extra_context: dict[str, Any] | None = None,
) -> HttpResponse
Wrap the change view in the request's RLS context.
Source code in django_rls_tenants/tenants/admin.py
delete_view
¶
delete_view(
request: HttpRequest,
object_id: str,
extra_context: dict[str, Any] | None = None,
) -> HttpResponse
Wrap the delete view in the request's RLS context.
Source code in django_rls_tenants/tenants/admin.py
history_view
¶
history_view(
request: HttpRequest,
object_id: str,
extra_context: dict[str, Any] | None = None,
) -> HttpResponse
Wrap the history view in the request's RLS context.
Source code in django_rls_tenants/tenants/admin.py
get_exclude
¶
Hide the tenant FK when the effective tenant is implicit.
For a scoped user, or an admin who has selected a tenant, the tenant is
known and set by :meth:save_model, so the field is excluded. A global
admin with no selection keeps it visible to choose a tenant explicitly.
Source code in django_rls_tenants/tenants/admin.py
get_fieldsets
¶
Drop the tenant FK from explicit fieldsets when the tenant is implicit.
Mirrors :meth:get_exclude: when the effective tenant is known the FK is
excluded from the form, so a fieldsets layout that still names it would
raise KeyError at render time. A global admin with no selection keeps
the field (and its layout slot) so they can assign a tenant explicitly.
Source code in django_rls_tenants/tenants/admin.py
save_model
¶
Stamp the effective tenant onto the object before saving.
When the tenant FK is hidden (implicit tenant) the form never sets it, so
it is assigned here. When the effective tenant is None (global admin
viewing all) the field is visible and the admin's own choice stands.
Source code in django_rls_tenants/tenants/admin.py
get_list_filter
¶
Prepend the tenant switcher for switch-capable admins.
Source code in django_rls_tenants/tenants/admin.py
django_rls_tenants.tenants.admin.TenantSwitchListFilter
¶
TenantSwitchListFilter(
request: HttpRequest,
params: dict[str, list[str]],
model: type[Model],
model_admin: ModelAdmin[Any],
)
Bases: SimpleListFilter
Changelist filter that renders the cross-tenant admin's tenant switcher.
The dropdown lists every tenant from RLS_TENANTS["TENANT_MODEL"] plus an
"All" entry. Selecting an option reloads the changelist with
?<param>=<tenant-pk> (or ?<param>=__all__); the owning
:class:RLSTenantModelAdmin persists that choice to the session and applies
the actual scoping by activating the matching context. The filter's own
:meth:queryset is therefore a no-op -- it exists to render the control
and to register the query parameter so the changelist accepts it.
Only attached for users who may switch tenants (see
:meth:RLSTenantModelAdmin.get_list_filter).
Source code in django_rls_tenants/tenants/admin.py
lookups
¶
Return (pk, label) pairs for every tenant.
The tenant model is global (not RLS-protected), so this is unaffected by the active context and always lists all tenants.
Source code in django_rls_tenants/tenants/admin.py
value
¶
Return the selected tenant id.
The query parameter wins; otherwise fall back to the session-persisted selection so the dropdown still shows the active tenant on add/change pages that carry no query string.
Source code in django_rls_tenants/tenants/admin.py
queryset
¶
choices
¶
Yield the "All" entry (clearing the selection) followed by each tenant.
Source code in django_rls_tenants/tenants/admin.py
Types¶
django_rls_tenants.tenants.types.TenantUser
¶
Bases: Protocol
Protocol that user objects must satisfy for RLS context resolution.
Implement these two properties on your User model::
class User(AbstractUser, RLSProtectedModel):
@property
def is_tenant_admin(self) -> bool:
return self.role.name == "ADMIN"
@property
def rls_tenant_id(self) -> int | str | None:
return self.tenant_id if self.tenant_id else None
Bypass Helpers¶
django_rls_tenants.tenants.bypass.set_bypass_flag
¶
Set a bypass flag on the current database connection.
The flag name should match one of the extra_bypass_flags
configured on an RLSConstraint::
set_bypass_flag("rls.is_login_request")
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
flag_name
|
str
|
GUC variable name (e.g., |
required |
is_local
|
bool
|
If |
False
|
using
|
str
|
Database alias. Default: |
'default'
|
Source code in django_rls_tenants/tenants/bypass.py
django_rls_tenants.tenants.bypass.clear_bypass_flag
¶
Clear a bypass flag on the current database connection.
Pass the same is_local that was used in set_bypass_flag so a
transaction-scoped flag is cleared transaction-locally too::
with transaction.atomic():
set_bypass_flag("rls.is_login_request", is_local=True)
...
clear_bypass_flag("rls.is_login_request", is_local=True)
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
flag_name
|
str
|
GUC variable name (e.g., |
required |
is_local
|
bool
|
If |
False
|
using
|
str
|
Database alias. Default: |
'default'
|
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If |
Source code in django_rls_tenants/tenants/bypass.py
Testing¶
django_rls_tenants.tenants.testing.rls_bypass
¶
Temporarily enable admin bypass for tests.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
using
|
str
|
Database alias. Default: |
'default'
|
Usage::
with rls_bypass():
all_orders = Order.objects.all() # sees all tenants
Source code in django_rls_tenants/tenants/testing.py
django_rls_tenants.tenants.testing.rls_as_tenant
¶
Scope to a specific tenant for tests.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
tenant_id
|
int | str
|
The tenant PK to scope to. |
required |
using
|
str
|
Database alias. Default: |
'default'
|
Usage::
with rls_as_tenant(tenant_id=42):
orders = Order.objects.all() # only tenant 42
Source code in django_rls_tenants/tenants/testing.py
django_rls_tenants.tenants.testing.assert_rls_enabled
¶
Assert that RLS is enabled and forced on the given table.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
table_name
|
str
|
The database table name to check. |
required |
using
|
str
|
Database alias. Default: |
'default'
|
Raises:
| Type | Description |
|---|---|
AssertionError
|
If RLS is not enabled or not forced. |
Source code in django_rls_tenants/tenants/testing.py
django_rls_tenants.tenants.testing.assert_rls_policy_exists
¶
assert_rls_policy_exists(
table_name: str,
policy_name: str | None = None,
*,
using: str = "default",
) -> None
Assert that an RLS policy exists on the given table.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
table_name
|
str
|
The database table name to check. |
required |
policy_name
|
str | None
|
Expected policy name. Defaults to
|
None
|
using
|
str
|
Database alias. Default: |
'default'
|
Source code in django_rls_tenants/tenants/testing.py
django_rls_tenants.tenants.testing.assert_rls_blocks_without_context
¶
Assert that the database returns zero rows of model_class with no RLS context.
Verifies the fail-closed behavior of the table's RLS policy. Requires at least one row in the table (the caller must set up test data first).
- Inside :func:
~django_rls_tenants.tenants.context.admin_context, the table must hold at least one row, so the assertion cannot pass vacuously. - With the admin and tenant GUCs cleared on the
usingconnection, the table must return zero rows. The GUCs are restored before the assertion (withSET LOCALwhenUSE_LOCAL_SET=True), so the result does not depend on the caller's admin or tenant context, and such a context (for example :func:rls_bypassor :func:rls_as_tenant) is intact when the helper returns or fails. WithUSE_LOCAL_SET=Trueoutsidetransaction.atomic(), where such a context lives in theContextVarvalues only and the GUC affinity guard applies it to each statement, the count runs without it, and the GUCs are cleared and restored for the session. Other GUCs, such as a flag set withbypass_flag(), are left as they are.
Both counts are raw SQL on model_class._meta.db_table, so the
STRICT_MODE guard, the ORM auto-scope filter and the filters of the
model's default manager are not involved: the helper works with
STRICT_MODE=True, and every row in the table must be hidden.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model_class
|
type[Model]
|
The RLS-protected model class to query. |
required |
using
|
str
|
Database alias. Default: |
'default'
|
Raises:
| Type | Description |
|---|---|
AssertionError
|
If any rows are returned, or if the table is empty (which would make the assertion pass vacuously). |
RuntimeError
|
If |
Source code in django_rls_tenants/tenants/testing.py
Async Testing Helpers¶
For async def tests. Which database connection they use depends on how the test runs the
coroutine; see Async Tests.
django_rls_tenants.tenants.testing.async_rls_bypass
async
¶
Async counterpart of :func:rls_bypass, for async with in async tests.
Wraps :func:~django_rls_tenants.tenants.context.async_admin_context: one
thread_sensitive hop on entry and one on exit, with the admin state set
in the test coroutine itself, so the async ORM sees it. The hops reach the
test's own connection and transaction only when the coroutine is driven by
async_to_sync, as Django's TestCase does for async def test
methods (see the testing guide, "Async Tests").
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
using
|
str
|
Database alias. Default: |
'default'
|
Raises:
| Type | Description |
|---|---|
RLSConfigurationError
|
If |
Usage::
async with async_rls_bypass():
total = await Order.objects.acount() # sees all tenants
Source code in django_rls_tenants/tenants/testing.py
django_rls_tenants.tenants.testing.async_rls_as_tenant
async
¶
Async counterpart of :func:rls_as_tenant, for async with in async tests.
Wraps :func:~django_rls_tenants.tenants.context.async_tenant_context,
with the same hop model as :func:async_rls_bypass.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
tenant_id
|
int | str
|
The tenant PK to scope to. |
required |
using
|
str
|
Database alias. Default: |
'default'
|
Raises:
| Type | Description |
|---|---|
NoTenantContextError
|
If |
RLSConfigurationError
|
If |
Usage::
async with async_rls_as_tenant(tenant_id=42):
orders = [order async for order in Order.objects.all()] # only tenant 42
Source code in django_rls_tenants/tenants/testing.py
django_rls_tenants.tenants.testing.aassert_rls_enabled
async
¶
Async counterpart of :func:assert_rls_enabled.
Runs the sync assertion in one thread_sensitive sync_to_async hop.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
table_name
|
str
|
The database table name to check. |
required |
using
|
str
|
Database alias. Default: |
'default'
|
Raises:
| Type | Description |
|---|---|
AssertionError
|
If the table does not exist, or RLS is not enabled or not forced on it. |
Source code in django_rls_tenants/tenants/testing.py
django_rls_tenants.tenants.testing.aassert_rls_policy_exists
async
¶
aassert_rls_policy_exists(
table_name: str,
policy_name: str | None = None,
*,
using: str = "default",
) -> None
Async counterpart of :func:assert_rls_policy_exists.
Runs the sync assertion in one thread_sensitive sync_to_async hop.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
table_name
|
str
|
The database table name to check. |
required |
policy_name
|
str | None
|
Expected policy name. Defaults to
|
None
|
using
|
str
|
Database alias. Default: |
'default'
|
Raises:
| Type | Description |
|---|---|
AssertionError
|
If the policy does not exist on the table. |
Source code in django_rls_tenants/tenants/testing.py
django_rls_tenants.tenants.testing.aassert_rls_blocks_without_context
async
¶
Async counterpart of :func:assert_rls_blocks_without_context.
Runs the sync assertion in one thread_sensitive sync_to_async
hop: the admin pre-check, the clearing of the admin and tenant GUCs, the
no-context count and the restore all happen inside it, on the connection
that holds the caller's GUCs. Like the sync helper it works with
STRICT_MODE=True and inside :func:async_rls_bypass or
:func:async_rls_as_tenant, and leaves their context intact. Requires at
least one row in the table.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model_class
|
type[Model]
|
The RLS-protected model class to query. |
required |
using
|
str
|
Database alias. Default: |
'default'
|
Raises:
| Type | Description |
|---|---|
AssertionError
|
If any rows are returned, or if the table is empty (which would make the assertion pass vacuously). |
RuntimeError
|
If |
Source code in django_rls_tenants/tenants/testing.py
Connection Pool¶
Hook for Django's psycopg connection pool (OPTIONS["pool"], Django 5.1+). It is not
re-exported from the top-level package; import it from django_rls_tenants.pool, which is
safe to import from settings.py. See
Django's Connection Pool.
django_rls_tenants.pool.reset_rls_gucs
¶
Clear the RLS GUCs on a connection returned to Django's psycopg pool.
Pass it as the pool's reset callback; Django forwards every key of
OPTIONS["pool"] to psycopg_pool.ConnectionPool::
from django_rls_tenants.pool import reset_rls_gucs
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
# ...
"OPTIONS": {"pool": {"reset": reset_rls_gucs}},
},
}
The pool calls it on the raw psycopg connection, in one of its worker
threads, after rolling back any transaction left open. It clears every GUC
the library's RLS policies read (the configured tenant and admin GUCs, and
each RLSConstraint's GUCs and extra_bypass_flags) in one statement
and leaves the connection idle, as the pool requires. If it raises, the pool
discards the connection instead of reusing it.
Unlike DISCARD ALL or RESET ALL, it keeps the session settings
Django applies only once per pooled connection, such as the time zone.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
conn
|
_PoolConnection
|
The psycopg 3 connection being returned to the pool. |
required |
Source code in django_rls_tenants/pool.py
Contrib¶
Optional integrations with third-party libraries. These are not re-exported from the top-level package; import them from their module.
Celery¶
Native Celery integration. Requires the celery extra
(pip install django-rls-tenants[celery]); import from
django_rls_tenants.contrib.celery. See the Celery Tasks guide
for usage, chains/groups, and the install() escape hatch.
django_rls_tenants.contrib.celery.rls_task
¶
Define a Celery task that propagates the RLS context. Like shared_task.
A thin wrapper over :func:celery.shared_task that defaults base to
:class:RLSTask. Use it exactly as you would shared_task -- bare or with
options::
from django_rls_tenants.contrib.celery import rls_task
@rls_task
def reindex(): ...
@rls_task(bind=True, max_retries=3)
def sync(self): ...
Enqueue inside an RLS context and the worker runs the body scoped to the same tenant::
with tenant_context(tenant.pk):
reindex.delay() # runs on the worker under tenant_context(tenant.pk)
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
*args
|
Any
|
Forwarded to |
()
|
**options
|
Any
|
Forwarded to |
{}
|
Returns:
| Type | Description |
|---|---|
Any
|
The shared task, or a decorator when called with options. |
Source code in django_rls_tenants/contrib/celery.py
django_rls_tenants.contrib.celery.RLSTask
¶
Bases: Task
Celery task base that carries the RLS context from caller to worker.
On enqueue (apply_async on a real broker, or apply in eager mode and
for canvas steps) the active tenant/admin context is captured into the task
headers. On the worker, :meth:__call__ reads those headers and runs the
body inside the matching tenant_context() / admin_context(), which
restores cleanly on both success and exception.
Prefer the :func:rls_task decorator; subclass or pass
shared_task(base=RLSTask) only when you need a custom base.
Attributes:
| Name | Type | Description |
|---|---|---|
rls_require_context |
bool
|
When |
apply_async
¶
Capture the active context into headers, then enqueue normally.
Source code in django_rls_tenants/contrib/celery.py
apply
¶
Capture context for eager execution and for canvas steps.
Eager mode and canvas (chain/group) dispatch call apply directly
rather than going through apply_async, so the capture is wired in
here as well to cover those paths.
Source code in django_rls_tenants/contrib/celery.py
__call__
¶
Restore the propagated RLS context around the task body.
Source code in django_rls_tenants/contrib/celery.py
django_rls_tenants.contrib.celery.install
¶
Globally propagate RLS context for all Celery tasks, via signals.
Connects before_task_publish (capture into headers) and
task_prerun / task_postrun (restore around the body) so context flows
even for tasks that are not based on :class:RLSTask. Use it as an escape
hatch for third-party or legacy tasks you cannot re-base.
Prefer :func:rls_task / :class:RLSTask where you can: they restore the
context for the whole body including its own apply_async calls, are
scoped per task, and need no global wiring. install() and the base class
compose safely -- RLSTask instances are skipped by the signal handlers.
Call it once during startup (for example in your Celery app module). It is
idempotent: a repeated call does not double-connect. Reverse it with
:func:uninstall.
Source code in django_rls_tenants/contrib/celery.py
django_rls_tenants.contrib.celery.uninstall
¶
Disconnect the signal handlers connected by :func:install.
Idempotent: safe to call when :func:install was never called. Does not
affect tasks based on :class:RLSTask, which never relied on the signals.
Any contexts still open from :func:_task_prerun are exited here as a
best-effort cleanup so a stale context cannot leak into the next task.
Warning
Do not call this while tasks are still executing on other threads. A
ContextVar token can only be reset on the thread that created it, and
Django database connections are thread-local, so this cleanup only
unwinds contexts entered on the calling thread -- an in-flight task on a
worker thread keeps its context until it finishes. Call uninstall()
at shutdown, or from the worker thread between tasks.