Changelog¶
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased¶
1.3.0 - 2026-06-28¶
Added¶
-
Tenant-aware Django admin (#31): a new
RLSTenantModelAdminmixin (subclass it instead ofadmin.ModelAdmin) runs every admin DB operation inside the RLS context implied by the logged-in user. It wraps the changelist/add/change/ delete/history views so the active context spans lazy querysets, forms, related dropdowns, and saves; changelists and related dropdowns are scoped via the existing manager auto-scoping (noget_querysetoverride). The tenant FK is hidden from the form and from any explicitfieldsetslayout, and auto-assigned when the tenant is implicit; cross-tenant admins get a session-backed tenant switcher (a changelist filter), and a non-admin user with no tenant is denied withPermissionDenied(carrying the #34 hint) rather than silently elevated. Behaviour is configured with class attributes only — no newRLS_TENANTSkeys.RLSTenantModelAdminis re-exported from the top-level package. Pair it withRLSTenantMiddlewareso site-level admin views (e.g. autocomplete) are scoped too. Sync only. -
Actionable error hints (#34):
RLSTenantErrornow accepts a keyword-onlyhint=argument and exposes.message/.hintattributes for programmatic access. TheNoTenantContextErrorraised bytenant_context(),admin_context(),_resolve_user_guc_vars(), the@with_rls_contextdecorator, andSTRICT_MODEnow carries aHint:line telling you exactly how to fix it, and theW001/W002/W008/W009system-check hints name the exact setting (and, forW009, the missing field). Backward compatible:str(exc)is unchanged when no hint is supplied. -
Code of Conduct (#35): adopted the Contributor Covenant v2.1 (
CODE_OF_CONDUCT.md), linked from the contributing guides and the documentation. Enforcement contact: dvoraj75@gmail.com. -
Raw SQL helper
safe_tenant_sql()(#33): aWHERE-clause fragment that scopes raw queries to the current tenant using the samecurrent_setting()expression as the RLS policies (including the #57 InitPlan form); the GUC names and the tenant PK cast come fromRLS_TENANTS. Supports the sameextra_bypass_flagsasRLSConstraint, so the fragment can mirror a policy's full bypass set. Adds a companioncurrent_tenant_value_sql()forINSERT/SELECTvalue lists. Both contain no bind parameters (the tenant id is read in-database from the session GUC), validate every interpolated identifier, are re-exported from the top-level package, and are documented in the new Raw SQL guide. -
Native Celery integration (#32): a new optional
celeryextra (pip install django-rls-tenants[celery]) addsdjango_rls_tenants.contrib.celery.@rls_task(ashared_taskdrop-in) and theRLSTaskbase class capture the active tenant/admin context into the task's message headers on enqueue and restore it on the worker — around the whole body, so it unwinds on both success and exception — without passingtenant_idby hand. Context propagates across chains, groups, and chords (every step must use the integration). Setrls_require_context = Trueon anRLSTasksubclass to fail fast (with the #34 hint) instead of running unscoped.install()/uninstall()wire the same behaviour globally via Celery signals as an escape hatch for tasks that cannot be re-based. Celery stays optional and is not re-exported from the top-level package; importing the module without Celery raises a clearImportError. Synchronous task bodies only (async defis out of scope for v1.3.0).
Changed¶
- Performance: InitPlan-wrapped GUC reads (#57): RLS policy predicates now
read PostgreSQL session variables through an uncorrelated scalar sub-SELECT
--
(SELECT current_setting('rls.current_tenant', true))-- so each GUC is evaluated once per statement (a planner InitPlan) instead of once per row. The predicate is otherwise unchanged, so query results are identical; the win shows on large, admin, and raw-SQL scans. Applies toRLSConstraint,RLSM2MConstraint, theAddM2MRLSPolicymigration operation, and thesetup_m2m_rlscommand (which also stops hardcodingrls.is_admin/rls.current_tenant/intand instead derives the GUC names and tenant PK cast fromRLS_TENANTS). Existing policies are not rewritten automatically -- see the migration guide ("From 1.2.2 to 1.3.0") to adopt the new form.
1.2.2 - 2026-06-27¶
Added¶
setup_m2m_rls --verbose(#28): print each M2M through-table policy'sCREATE POLICYSQL before applying it, so DBAs and security reviewers can audit the exact SQL while still applying it in one run. Combine with--dry-runto print without executing.check_rls --verbose(#26): print each policy's liveUSING/WITH CHECKdefinition (read from thepg_policiesview — the SQL PostgreSQL actually enforces) under the pass/fail line, for auditing and diagnosing unexpected policy behaviour.--quiettakes precedence when both are given.- System checks
W008+W009(#29):W008warns whenRLS_TENANTS['TENANT_MODEL']does not resolve to an installed model;W009warns when a concreteRLSProtectedModelsubclass is missing the tenant field its RLS policy references (resolved fromRLSConstraint(field=…), falling back toTENANT_FK_FIELD). Both surface the misconfiguration at startup viamanage.py checkinstead of as a cryptic error at migrate/query time. - Documentation: Celery quick-start guide (#30): new Celery Tasks guide
showing how to wrap task bodies in
tenant_context()so RLS context is set for work that runs outside the request cycle. Interim pattern until native Celery integration lands in v1.3.0.
1.2.1 - 2026-06-27¶
Added¶
- DEBUG-level logging (#24):
logger.debug()calls in middleware, context managers, and M2M auto-detection for tracing the RLS context lifecycle. Uses Django's logging framework, so there is zero output unless DEBUG is enabled on thedjango_rls_tenantslogger. RLSTenantMiddleware: logs when GUCs are set (with user type, admin status, and tenant ID) and when they are cleared.tenant_context()/admin_context(): log entry (with tenant ID and database alias) and exit.register_m2m_rls(): logs skip reasons during M2M auto-detection (explicit through model, constraint already exists, unresolved lazy reference, neither side protected).check_rls --quiet(#27): suppress success output and report only errors, for clean CI/CD pipeline runs. Errors and the non-zero exit code are unaffected.__repr__on key classes (#23): added__repr__()toRLSConstraint,RLSM2MConstraint, andRLSTenantsConfigfor readable output in tracebacks, shell sessions, and test failures.
Changed¶
- Public API surface cleanup (#20): Removed internal helpers from
top-level
__all__and_LAZY_IMPORTSin__init__.py. The following symbols are no longer re-exported fromdjango_rls_tenants: - 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 - Exception classes:
NoTenantContextError,RLSConfigurationError,RLSTenantError
These remain importable from their actual modules
(django_rls_tenants.tenants.state and django_rls_tenants.exceptions).
This guides users toward the safe context manager APIs (tenant_context,
admin_context) instead of direct state manipulation.
- Type annotation completeness for public API (#25): tightened the
@with_rls_context decorator signature using ParamSpec and TypeVar, so
type checkers and IDEs correctly infer the parameter and return types of
decorated functions instead of collapsing to Callable[..., Any]. Verified
the return annotations on RLSManager.get_queryset(), RLSManager.for_user(),
set_guc(), get_guc(), and clear_guc().
- Code comment consistency (#22): added module docstrings to the
management packages, SQL-safety comments on the parameterised pg_class /
pg_policies queries in check_rls, and explanatory text on the
type: ignore comments in managers.py.
Fixed¶
- Documentation accuracy pass (#21): corrected inaccuracies across CHANGELOG, SECURITY.md, README.md, and docs/ pages.
- SECURITY.md: updated supported versions table to include 1.1.x and 1.2.x.
- CHANGELOG.md: fixed strict mode PR references from #13 to #14. Corrected
claim that
for_user()sets_rls_context_active(it satisfies strict mode via_rls_userinstead). - README.md: simplified Quick Start config to show only the required
TENANT_MODELkey instead of listing all defaults (which were incomplete). - docs/why-rls.md: replaced incorrect
COALESCE-based policy example with the actualCASE WHEN/nullif()pattern used byRLSConstraint. Fixed GUC-setting description to distinguishset_config()fromSET LOCAL.
1.2.0 - 2026-03-21¶
Added¶
- M2M join table RLS support (#11): subquery-based RLS policies for auto-generated M2M through tables, ensuring tenant isolation extends to many-to-many relationships.
RLSM2MConstraint: migration-awareBaseConstraintthat generatesEXISTS-based subquery policies on M2M join tables. Supports both-sides-protected, one-side-protected, and self-referential M2M.AddM2MRLSPolicy: reversible Django migration operation for applying M2M RLS policies. All inputs validated against SQL injection.register_m2m_rls(): auto-detection inAppConfig.ready()that discovers M2M fields onRLSProtectedModelsubclasses and registersRLSM2MConstrainton their auto-generated through tables.setup_m2m_rlsmanagement command for retroactive M2M RLS application on existing deployments (with--dry-runand--databaseflags).check_rlsnow also verifies RLS on M2M through tables.STRICT_MODEconfiguration option. When enabled,TenantQuerySetevaluation methods (count(),exists(),aggregate(),update(),delete(),iterator(),bulk_create(),bulk_update(),get(),first(),last(), and iteration via_fetch_all()) raiseNoTenantContextErrorif no RLS context is active. Off by default. (#14)_rls_context_activeContextVar instate.pywith public accessorsget_rls_context_active(),set_rls_context_active(), andreset_rls_context_active(). Tracks whether any RLS context (tenant or admin) is active, enabling strict mode to distinguish "no context" from "admin context". (#14)- Custom exception hierarchy in
django_rls_tenants.exceptions:RLSTenantError(base),NoTenantContextError,RLSConfigurationError. All importable from the top-leveldjango_rls_tenantspackage. (#12) DATABASESconfiguration option for multi-database GUC support. The middleware now sets GUCs on all configured database aliases, not justdefault. Default:["default"](backward compatible). (#9)connection_createdsignal handler that sets GUCs on lazily created database connections mid-request (e.g., replica connections opened by a database router).check_rls --databaseflag for verifying RLS on non-default databases.- System checks
W006(invalid database alias inDATABASES) andW007(USE_LOCAL_SET=TruewithoutATOMIC_REQUESTSon configured databases).
Changed¶
tenant_context(),admin_context(), andRLSTenantMiddlewarenow set_rls_context_active=Trueon entry and restore the previous value on exit. This enables strict mode's "no context" detection.for_user()satisfies strict mode via the stored_rls_userreference. (#14)tenant_context()and_resolve_user_guc_vars()now raiseNoTenantContextErrorinstead ofValueErrorwhen a non-admin user hasrls_tenant_id=Noneor whentenant_idisNone.@with_rls_contextdecorator now raisesNoTenantContextErrorinstead ofValueErrorwhen a non-admin user hasrls_tenant_id=None.RLSTenantsConfig._get()now raisesRLSConfigurationErrorinstead ofValueErrorwhen a required config key (e.g.,TENANT_MODEL) is missing.
1.1.0 - 2026-03-17¶
Added¶
get_current_tenant_id()/set_current_tenant_id()/reset_current_tenant_id()functions for custom middleware and management commands that need direct access to the auto-scope state. Use the token returned byset_current_tenant_id()withreset_current_tenant_id(token)to safely restore the previous value.W005system check that warns when the default database connection uses a PostgreSQL superuser. Superusers bypass all RLS policies, completely disabling tenant isolation.
Changed¶
- Automatic query scoping:
RLSManager.get_queryset()now addsWHERE tenant_id = Xautomatically when a tenant context is active (viatenant_context(),admin_context(), orRLSTenantMiddleware). This enables PostgreSQL to use composite indexes, eliminating the sequential scan penalty of RLScurrent_setting()calls. No code changes required -- activates automatically.for_user()continues to work as before. - RLS policy rewrite:
RLSConstraintnow generatesCASE WHENpolicies instead ofOR-based policies, improving readability and clarifying the evaluation structure. Existing policies are updated on the next migration. (Note: the primary performance improvement comes from auto-scoping above, which enables index usage. TheCASE WHENrewrite is a clarity improvement, not a performance optimization.) TenantQuerySet.select_related()now auto-propagates tenant filters to joined RLS-protected tables when a tenant context is active.- Middleware GUC-set tracking now uses
ContextVarinstead ofthreading.local, ensuring proper isolation in ASGI (async) deployments where multiple coroutines share a single thread. - Middleware adds
process_exception()handler that cleans up bothContextVarstate and GUCs when an unhandled view exception preventsprocess_responsefrom running. request_finishedsafety-net signal handler now also resets theContextVarauto-scope state, not just the GUC variables._resolve_user_guc_vars()now raisesValueErrorfor non-admin users withrls_tenant_id=Noneinstead of stringifyingNone. This catches user-model misconfigurations at middleware/context-manager time rather than producing a silent mismatch at the database level.@with_rls_contextnow validatesrls_tenant_idbefore entering the tenant context (fail-fast), providing a clear error message that includes the function name.
Fixed¶
_add_tenant_fksignal handler now reads the configuredTENANT_FK_FIELDvalue instead of hardcoding"tenant"when checking for an existing field. Previously, a customTENANT_FK_FIELD(e.g.,"organization") would cause the handler to miss existing fields and attempt to add a duplicate FK.W004system check now correctly detectsCONN_MAX_AGE=None(Django's "keep connections forever" sentinel). Previously, only positive integer values were flagged;Nonesilently passed the check despite being the most dangerous value for GUC leak risk.
1.0.0 - 2026-03-15¶
Initial stable release of django-rls-tenants.
Added¶
Architecture¶
- Two-layer architecture with strict import boundary enforced by tests:
rls/layer: generic PostgreSQL RLS primitives with zero Django model knowledge.tenants/layer: opinionated Django multitenancy built on therls/layer.
RLS Primitives (rls/)¶
set_guc,get_guc,clear_guchelpers for managing PostgreSQL session variables (GUCs), with regex-based SQL injection prevention.RLSConstraintfor generatingCREATE POLICY/DROP POLICYDDL in Django migrations, with support forint,bigint, anduuidprimary key types.rls_contextgeneric context manager for setting/clearing arbitrary GUC variables with save/restore nesting support.bypass_flagcontext manager for toggling boolean bypass flags within a transaction-scoped context.
Tenant Models & Managers (tenants/)¶
RLSProtectedModelabstract base class with dynamic tenant foreign key added via theclass_preparedsignal. Supports auto-generated and explicit FK configurations.TenantQuerySetwith lazy GUC evaluation at query execution time (not queryset creation time), solving the lazy evaluation problem for chained queries.RLSManagerwithfor_user()for scoped queries andprepare_tenant_in_model_data()for efficient bulk creation without N+1SELECTqueries.- Defense-in-depth:
for_user()applies both Django ORM.filter()and database-level RLS, so even if one layer is bypassed the other provides isolation.
Middleware & Context¶
RLSTenantMiddlewarefor automatic per-request RLS context based on the authenticated user. API-agnostic (works with REST, GraphQL, Django views).tenant_contextandadmin_contextcontext managers with nesting support and automatic GUC cleanup viatry/finally.@with_rls_contextdecorator for extracting user context from function arguments, with configurableuser_paramand fail-closed behavior.request_finishedsignal safety net for GUC cleanup in case middleware'sprocess_responsedoes not run.
Configuration & Validation¶
- Single
RLS_TENANTSsettings dict with 6 configuration keys:TENANT_MODEL,TENANT_FK_FIELD,USER_PARAM_NAME,GUC_PREFIX,TENANT_PK_TYPE,USE_LOCAL_SET. RLSTenantsConfigsingleton with lazy property access and unknown-key detection (warns on typos).- Django system checks:
W001(GUC prefix mismatch for tenant),W002(GUC prefix mismatch for admin),W003(USE_LOCAL_SETwithoutATOMIC_REQUESTS),W004(CONN_MAX_AGE > 0with session-scoped GUCs).
User Integration¶
TenantUserruntime-checkableProtocolfor structural subtyping of user objects. Requiresis_tenant_adminandrls_tenant_idproperties.
Bypass Mode¶
bypass_flagcontext manager for temporary bypass of specific RLS policies.set_bypass_flag/clear_bypass_flagimperative helpers.extra_bypass_flagssupport onRLSConstraintfor custom bypass clauses (e.g., login flows).
Connection Pooling¶
USE_LOCAL_SETconfiguration for transaction-scoped GUCs viaSET LOCAL, compatible with PgBouncer and other connection poolers.
Management Commands¶
check_rlsmanagement command to verify RLS policies are correctly applied to all protected models, with CI-friendly exit codes.
Testing Utilities¶
rls_bypasscontext manager for disabling RLS in test setup/teardown.rls_as_tenantcontext manager for running tests as a specific tenant.assert_rls_enabledassertion for verifying RLS is active on a table.assert_rls_policy_existsassertion for verifying a named policy exists.assert_rls_blocks_without_contextassertion for verifying fail-closed behavior.
Package & Tooling¶
- PEP 561
py.typedmarker for typed package support. __version__attribute viaimportlib.metadata(single source of truth).- Full MkDocs Material documentation site with 19 pages: getting started, guides, advanced topics, API reference, and development docs.
- CI pipeline: Python {3.11, 3.12, 3.13, 3.14} x Django {4.2, 5.0, 5.1, 5.2, 6.0} test matrix with PostgreSQL 16.
- OIDC trusted publishing to PyPI via GitHub Actions.
- Security policy (
SECURITY.md) with vulnerability disclosure process.
Fixed¶
clear_gucnow accepts anis_localparameter, ensuring consistent GUC lifetimes whenUSE_LOCAL_SET=True. Previously,admin_context, middleware, and manager_fetch_allcould clear GUCs with session scope while setting them with transaction scope, causing mismatched lifetimes.