Context Managers¶
Context managers provide explicit control over RLS context in code that runs outside the request/response cycle -- management commands, Celery tasks, tests, scripts, and service-layer functions.
tenant_context¶
Scopes all database queries within the block to a specific tenant:
from django_rls_tenants import tenant_context
with tenant_context(tenant_id=42):
# All queries see only tenant 42's data
orders = Order.objects.all()
invoices = Invoice.objects.all()
Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
tenant_id |
int \| str |
(required) | The tenant PK to scope to. |
using |
str |
"default" |
Database alias. |
Behavior:
- Sets
rls.is_admin = 'false'andrls.current_tenant = str(tenant_id). - Saves and restores previous GUC values on exit (supports nesting).
- Raises
ValueErroriftenant_idisNone.
# ValueError: use admin_context() for admin access
with tenant_context(tenant_id=None): # raises ValueError
...
admin_context¶
Enables admin bypass -- all tenant data is visible:
from django_rls_tenants import admin_context
with admin_context():
# Sees data from ALL tenants
all_orders = Order.objects.all()
total = all_orders.count()
Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
using |
str |
"default" |
Database alias. |
Behavior:
- Sets
rls.is_admin = 'true'and clearsrls.current_tenant. - Saves and restores previous GUC values on exit (supports nesting).
Nesting¶
Context managers support arbitrary nesting. Each level saves the previous state and restores it on exit:
with admin_context():
# Admin: sees everything
all_count = Order.objects.count()
with tenant_context(tenant_id=1):
# Scoped to tenant 1
t1_count = Order.objects.count()
with tenant_context(tenant_id=2):
# Scoped to tenant 2
t2_count = Order.objects.count()
# Back to tenant 1
assert Order.objects.count() == t1_count
# Back to admin
assert Order.objects.count() == all_count
Note
Nesting only saves/restores when USE_LOCAL_SET is False (the default).
With USE_LOCAL_SET=True, GUC values are transaction-scoped and PostgreSQL
handles cleanup at transaction boundaries.
@with_rls_context Decorator¶
The with_rls_context decorator automatically extracts a user argument from a
function's signature and sets the appropriate RLS context:
from django_rls_tenants import with_rls_context
@with_rls_context
def process_order(request, as_user):
# RLS context set automatically based on as_user
orders = Order.objects.all()
return process(orders)
How It Works¶
- At decoration time: caches
inspect.signature()of the wrapped function. - At call time: extracts the user argument by name from
*args/**kwargs. - If the user is an admin (
is_tenant_admin=True): wraps inadmin_context(). - If the user is a tenant user: wraps in
tenant_context(user.rls_tenant_id). - If the user is
None: logs a warning and proceeds without context (fail-closed).
Default Parameter Name¶
By default, the decorator looks for a parameter named by the USER_PARAM_NAME setting
(default: "as_user"):
Custom Parameter Name¶
Use user_param to specify a different parameter name:
Signature Mismatch Warning¶
If the parameter is not found in the function signature, the decorator logs a warning at decoration time and the function will always run without RLS context (fail-closed):
Examples¶
# Bare decorator (uses default USER_PARAM_NAME)
@with_rls_context
def create_order(request, as_user):
Order.objects.create(title="New Order", amount=100)
# With explicit user_param
@with_rls_context(user_param="user")
def get_dashboard_data(user):
return {
"orders": Order.objects.count(),
"invoices": Invoice.objects.count(),
}
# Called as a regular function -- user is extracted automatically
create_order(request, as_user=tenant_user)
create_order(request, tenant_user) # also works (positional)
Multi-Database Support¶
All context managers accept a using parameter for multi-database setups:
with tenant_context(tenant_id=42, using="replica"):
orders = Order.objects.using("replica").all()
with admin_context(using="analytics"):
data = Report.objects.using("analytics").all()
The TenantQuerySet.for_user() method automatically uses self.db, so chaining
.using() works correctly: