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.4.0 - 2026-10-16¶
Removed¶
- Support for Django 4.2, 5.0 and 5.1 and Python 3.11 (#114). All three Django series
are past their upstream end of extended support, and Python 3.11 is not supported by
every remaining Django series. Under the new supported-versions policy this is not a
breaking change: 1.3.x stays on PyPI and keeps working there
(
pip install "django-rls-tenants<1.4").
Added¶
- Async GUC primitives and async context managers (#36, #37).
aset_guc(),aget_guc(),aclear_guc()andarestore_guc()indjango_rls_tenants.rls, andasync_tenant_context()andasync_admin_context()(both also exported top-level),async_rls_context()andasync_bypass_flag()forasync with. Django has no asynchronous database cursor, so these are thread-sensitivesync_to_asynchops, like the async ORM itself, not non-blocking calls: the managers make one hop on entry and one on exit, set and reset the tenantContextVars in the calling coroutine (so the auto-scope filter andSTRICT_MODEapply toacount()and friends, and the #58 token problem cannot occur), and support nesting with sync and async blocks in both GUC modes, including restore when the task is cancelled (even during entry). WithUSE_LOCAL_SET=Truethe tenant managers work without a transaction too (see "Transaction-local mode without a transaction" below); on an alias outsideRLS_TENANTS["DATABASES"]they raiseRLSConfigurationErrorunless the coroutine runs viaasync_to_syncinside a synchronoustransaction.atomic(). Sibling tasks of one request share its connection; the GUC affinity guard (#41, below) gives each task's statements its own context. No behaviour change for sync code. - Async testing helpers (#40).
django_rls_tenants.tenants.testinggainsasync_rls_bypass()andasync_rls_as_tenant()forasync with, plusaassert_rls_enabled(),aassert_rls_policy_exists()andaassert_rls_blocks_without_context(). They take the same arguments as their sync counterparts. The context managers wrapasync_admin_context()andasync_tenant_context(), and each assertion runs its sync version in one thread-sensitive hop. A new "Async Tests" section in the testing guide explains which connection an async test uses. DjangoTestCaseasync defmethods, and sync tests that drive a coroutine withasync_to_sync, share the test's transaction. pytest-asyncio and anyio tests run on a separate connection outside the test transaction. With plaindjango_db, their fixture rows are invisible and their writes are committed. - GUC affinity guard (#41). Every connection of an alias in
RLS_TENANTS["DATABASES"]now follows the RLS context of the code that uses it. Before each statement, an execute wrapper compares the admin and tenant GUC values of the active context with what the library last left on that database session, and when they differ applies the context's values first, in oneset_config()statement on the same connection. - Work handed to another thread with a copy of a live context
(
sync_to_async(..., thread_sensitive=False),asyncio.to_thread(), the error pages Django builds there under ASGI) runs with that context, on that thread's own connection, instead of with whatever GUCs the connection happened to hold. A copy that outlives its block (a task started and not awaited inside it) gets nothing once the block ends, and async_to_asynccall cancelled while its function is inside a block does not hand that block to a caller inside a context or a request the middleware serves. A block that exits in anothercontextvars.Contextthan it entered in (a generator closed or garbage-collected there) restores its snapshot only on the database session it entered on (the snapshot of the enclosing context only while that context is live), and the guard then applies the context that is current there. A block the view leaves open (a generator suspended inside it) ends before Django builds the error response for an exception (inprocess_exception, or ongot_request_exceptionfor a 500 responseprocess_exceptiondid not see), and when the request ends. WithUSE_LOCAL_SET=Trueunder WSGI, where the middleware keeps the request's transaction for the error response, that response then runs with no context. Not covered: a 4xx response for an exception raised outside the view (Http404from a later middleware, say), and a response that an exception middleware listed afterRLSTenantMiddlewarereturns. - Sibling
asynciotasks of one request run their statements with their own contexts, soasyncio.gatheracross tenants is safe for raw SQL,safe_tenant_sql()and M2M through tables too. - A connection used outside any context loses what a context left on it before its next statement, and a block's exit takes its context off the thread's other configured aliases at once.
- Only contexts the library set up are applied (
tenant_context(),admin_context(), their async variants,RLSTenantMiddleware,for_user()). The guard never infers one fromset_current_tenant_id()/set_rls_context_active(), and GUCs other code writes (set_guc(),rls_context()) stay in force until the context changes, as before; withUSE_LOCAL_SET=Truethe one exception is a statement run in autocommit inside a context, whose own transaction sets the context's values over them. - Code that stays on one connection runs no extra statement: the library records its own
GUC writes, and the guard follows commits, rollbacks and savepoints, which undo or keep
set_config()changes. Whatever it cannot follow, it applies again. - If applying fails, the error is raised and the statement does not run.
- With
USE_LOCAL_SET=Trueit writes the values transaction-locally (see "Transaction-local mode without a transaction" below). - Limits: the admin and tenant GUCs only, not bypass flags; SQL that bypasses Django's
execute wrappers (a driver cursor,
callproc(), the driver'scopymethods) is not seen. It costs about a microsecond of Python per statement. See "GUC Affinity Guard" in the architecture guide. - Transaction-local mode without a transaction (#96). With
USE_LOCAL_SET=Truethe GUC affinity guard (above) now applies the active context too, withset_config(..., true): the admin and tenant GUCs end with the transaction they were set in, committed or not, so no session, pooled or not, ever keeps them. - In a transaction the guard sets them before the first statement that needs them and
again when the context changes there: one statement per transaction for a context
entered before it. A context entered inside
transaction.atomic()still writes its own values (twocurrent_setting()reads and twoSET LOCALstatements on entry, two on exit); the guard sends none for it while its tracker is clean (a test transaction, the WSGI request transaction), but one clear first after an earlier context has ended on that connection, on every iteration of a loop that entersatomic()and a context. - A statement run in autocommit inside a context gets a transaction of its own:
BEGIN, the context's values, the statement,COMMIT, so three more round trips. The statement still commits, or fails and is rolled back, on its own (anexecutemany()as a whole); a server-side cursor it opens (iterator()) is computed at thatCOMMIT, as at the end of its implicit transaction in autocommit. The guard sendsBEGIN,COMMITandROLLBACKpast Django's cursor, so no execute wrapper sees them, and logs them as Django logs its own transaction control: they appear inconnection.queriesand on thedjango.db.backendslogger. A wrapped statement counts as four queries inassertNumQueries, listedBEGIN,set_config(),COMMIT, then the statement itself, which Django logs when it finishes. If theROLLBACKafter a failed statement cannot be confirmed, the guard closes the connection; the statement's own error is raised. - Run as they are, without the context, because PostgreSQL may refuse them in a
transaction block and none evaluates an RLS policy:
VACUUM,CLUSTER,REINDEX,DISCARD;CREATE/ALTER/DROPof a database, tablespace or subscription andALTER SYSTEM;CREATE [UNIQUE] INDEX CONCURRENTLY,DROP INDEX CONCURRENTLYandALTER TABLE ... DETACH PARTITION ... CONCURRENTLY. A procedure orDOblock that commits or rolls back cannot run inside a context in autocommit.SET TRANSACTIONis transaction control in both GUC modes: the guard writes nothing before it, so it can start anatomic()entered inside a context. - When a context ends (a block exits, a
for_user()call ends, a request ends), and on any connection after a statement run under a context's values (athread_sensitive=Falsehop's, say), what a session holds for itself counts as unknown: code inside the context may have written the admin or tenant GUC for the session (set_guc(),rls_context()withoutis_local=True, a function, aDOblock), and nothing restored it in this mode. Before the next statement run outside any context, in autocommit or in a transaction, the guard clears them for the session, one extra statement. - Limit: a statement run in autocommit inside a context gets the context's values in its
own transaction, which override such writes of the admin or tenant GUC for the session.
Narrowing an
admin_context()withrls_context({"rls.is_admin": "false", ...})has no effect there, though it does insideatomic()and in session mode. Change the context withtenant_context()/admin_context(), or write withis_local=Trueinsideatomic(). - The mode therefore no longer needs a transaction around your code. Outside
transaction.atomic(),tenant_context(),admin_context(), their async variants,@with_rls_context,for_user()querysets and the testing helpers (includingassert_rls_blocks_without_context()) work on every alias inRLS_TENANTS["DATABASES"]; the context managers andfor_user()write nothing on entry or exit there (a transaction still open at exit, fromAUTOCOMMIT=Falseor an SQLBEGIN, may get oneset_config()statement that puts the enclosing context's values back, or clears them for the session when there is none). Celery tasks, management commands and scripts need noatomic().RLSTenantMiddlewareserves the mode on its async path (ASGI, when every middleware listed after it is async-capable): no request transaction there, the guard applies the request's context to each statement. The synchronous path (WSGI, or ASGI with a synchronous-only middleware after it) keeps its transaction per authenticated request, so WSGI requests are atomic in this mode and requests on the async path are not. Insideatomic()nothing changes. A batch in oneatomic()stays the cheaper way: oneset_config()per transaction for a context entered before it, instead of three round trips per statement. - The guard is also installed on connections that were already open when the app's
ready()ran. - Behind PgBouncer in transaction mode every statement now sees its context in the
transaction that runs it, on the async path and outside
atomic()too. - Unchanged: on an alias outside
RLS_TENANTS["DATABASES"]the mode still needstransaction.atomic()(RuntimeError, orRLSConfigurationErrorfrom the async managers), bypass flags keep their explicitis_local, and the pool's checkout clear still runs. With this mode, a connection the request thread opens mid-request no longer gets aSET LOCALattempt that failed and logged a warning. System checkW004no longer calls the mode WSGI-only. RLSTenantMiddlewareends the request context when aBaseExceptionescapes the view (#41). Under WSGI, Django turns everyExceptioninto a response, but aBaseException(KeyboardInterrupt, a server's timeout) skippedprocess_response, and the thread's next request then started inside the previous request's context.reset_rls_gucs()for Django's connection pool (#96).django_rls_tenants.pool.reset_rls_gucsis aresetcallback forDATABASES[alias]["OPTIONS"]["pool"](Django 5.1+, psycopg 3).psycopg_poolcalls it when a connection goes back to the pool, and it clears every GUC the library's RLS policies read in one statement, so an idle pooled connection holds no tenant state. UnlikeDISCARD ALLorRESET ALL, it keeps the session settings Django applies only once per pooled connection, such as the time zone. If it raises, the pool discards the connection. See "Django's Connection Pool" in the connection pooling guide.- System check
W010(#96) warns for each alias inRLS_TENANTS["DATABASES"]that uses Django's connection pool withUSE_LOCAL_SET=Falseand no poolresetcallback. Anyreset, including your own, silences it.W004never covered this case: Django rejects a non-zeroCONN_MAX_AGEtogether with a pool. - Django 6.1 support (#114), with classifiers and CI legs.
- Explicit PostgreSQL 15–18 support (#114). The requirements tables list every supported major; CI runs 16 by default and 15 and 18 as the edges.
- psycopg 3 CI leg (#114) on Python 3.12 / Django 6.1. The library stays driver-neutral; psycopg2 remains the default in every other leg.
- Supported-versions policy in the installation guide ("Supported versions", #114): which Django, Python and PostgreSQL versions are supported, when they are added and dropped, and what dropping means.
tests/test_support_matrix.py(#114) cross-checks every file that states the matrix (pyproject.toml, CI, README, docs, AGENTS.md, both contributing guides, the example Dockerfile) and names each one that disagrees. Excluded from the sdist, since it reads files the sdist does not ship. The test suite also turns every Django deprecation warning into an error.- Weekly canary workflow (
.github/workflows/canary.yml, #114): Djangomain, the next Python (3.15) and the next PostgreSQL (19 beta). It blocks nothing; a red run means a matrix review is due. - Non-superuser CI leg (
test-plain-role, #131). The library suite also runs as a plain role (LOGIN CREATEDB, noBYPASSRLS) with-m "not superuser_only"on PostgreSQL 16, after a step that fails if the connection is a superuser or hasBYPASSRLS. Every other leg connects as thepostgressuperuser, which bypasses RLS: that is how #131 went unnoticed. The test fixtures no longer try to create a role on a plain-role connection,enforce_rlsnow also switches aBYPASSRLSconnection to a plain role, thefor_user()tests now requestenforce_rls, andtests/test_support_matrix.pykeeps the new leg on a supported Python and on PostgreSQL 16. psycopg3_onlytest marker (#96). Tests that need psycopg 3 andpsycopg_pool(the real-pool tests) carry it, andtests/conftest.pydeselects them when Django runs on psycopg2. They are deselected, not skipped, so the zero-skipped rule still holds. Only the psycopg 3 CI leg runs them; a missingpsycopg_poolthere fails them.- System check
W011: a database- or role-level default for a policy GUC.ALTER DATABASE ... SET rls.is_admin = 'true'(orALTER ROLE ... SET) puts that value in force at the start of every session of that database or role, before any RLS context is set, and the library never clears it: an admin value bypasses the policies in every session, a tenant value shows that tenant's rows to everyone. The check readspg_db_role_settingfor every GUC the installed policies read (the configured pair, the constraints' GUCs andextra_bypass_flags) and names theALTER ... RESETthat removes the default. - Async Deployment guide (
docs/guides/async.md, #42). One page for running on an ASGI server: the one-thread-one-connection-per-request model the library relies on; Uvicorn / Daphne / Hypercorn commands and what matters for RLS on each (which middleware path serves the request and how to tell, connections per worker process and how to cap them, WebSockets); a table of which connection setups are safe with eachUSE_LOCAL_SETvalue (CONN_MAX_AGE, Django's pool withreset_rls_gucs, PgBouncer in transaction and session mode); howContextVars travel across tasks,sync_to_async,async_to_syncand executor threads; whatUSE_LOCAL_SET=Truemeans without async transactions (no request transaction on the async path, three extra round trips per autocommit statement, grouping writes intransaction.atomic()inside a synchronous function); testing the async path; and a deployment checklist. It is in the site nav and the home page's "I want to..." table, and is linked from the README, Middleware, Context Managers, Connection Pooling, Configuration, Security Model and Troubleshooting. Troubleshooting gains four entries:You cannot use ATOMIC_REQUESTS with async views,SynchronousOnlyOperationin async code, an async view that committed some of its writes, andtoo many clients alreadyor pool timeouts under ASGI.
Changed¶
- Docs: the roadmap's v1.4.0 section (#42) describes the real connection-affinity
problems (code on other threads, sibling tasks sharing a connection, pooled connections)
instead of claiming that the sync middleware runs queries on a different connection from
its GUCs, and lists #126, #130 and both parts of #96. The Connection Pooling guide asks
for
DISABLE_SERVER_SIDE_CURSORS = Truebehind PgBouncer in transaction mode, and it and theW004troubleshooting entry say to keepCONN_MAX_AGE = 0under ASGI. requires-pythonis now>=3.12and the dependencydjango>=5.2(#114). The lockfile moves to Django 6.1.2 (django-stubs 6.1.2, mypy 2.4.0) and psycopg2-binary 2.9.13 (the first release with Python 3.15 wheels); the example Docker image installs Django 6.1.RLSTenantMiddlewareserves ASGI requests on its own async path (#38). A hand-written__acall__replacesMiddlewareMixin.__acall__: one thread-sensitive hop readsrequest.userand sets the GUCs on every alias, the tenantContextVars are set and reset with their own tokens in the request coroutine, and one hop clears the GUCs (none ifprocess_exceptionalready did). Django has no asynchronous database cursor, so the GUC work still runs in those hops; the gain is correctness: the #58 token problem cannot occur, and a context active around the middleware is restored instead of cleared. A cancellation during the entry hop is undone.process_exceptionstill ends the context before Django builds the error response on another thread. A subclass that overridesprocess_requestorprocess_responsekeeps the 1.3.4 hook-based path, so its hooks still run under ASGI. The WSGI path and streaming bodies (iterated after cleanup, fail-closed) are unchanged.USE_LOCAL_SET=True, which this path refused, is supported since #96 (see "Added").for_user()withUSE_LOCAL_SET=Trueoutside a transaction (#131, #96). Everyfor_user()method now sets the user's GUCs, withSET LOCALin this mode.update_or_create(),bulk_create(),bulk_update()anddelete(), which Django runs in a transaction anyway, open one when none is open; on an alias inRLS_TENANTS["DATABASES"]the guard then sets the user's values in it (oneset_config()) and takes them off at the scope's exit (one more). On such an alias the other methods (count(),exists(),aggregate(),iterator(),update(),create(), ...) leave the GUCs to the GUC affinity guard, which runs each statement in a transaction of its own (see "Added"); on any other alias they raiseRuntimeErroroutsidetransaction.atomic(), as iteration always did. Before, they ran without the GUCs (empty results under RLS, or all rows on a superuser connection).- Every alias in
RLS_TENANTS["DATABASES"]follows the active context (#41). A context now applies to every configured alias, whichever alias it was entered on, through the GUC affinity guard. A read that a router sends to a configured replica insidetenant_context(5)is scoped to tenant 5 (before, it ran with whatever GUCs the replica connection held, usually none, and found no rows).admin_context(using="replica")gives the admin bypass on every configured alias, as the tenantContextVars and the ORM auto-scope filter already did, even whenreplicaitself is not configured:usingnames the alias written to first, not a limit. An alias outside the list does not follow a context entered on another alias. Since #96 this holds withUSE_LOCAL_SET=Truetoo. @with_rls_contextrejects generator functions and coroutine results (#39). Decorating a generator or async generator function now raisesTypeErrorat decoration time: its body runs while the caller iterates, after the decorator has left the context, so it never ran under the decorated user's context. A function decorated as synchronous that returns a coroutine (an object withasync def __call__, or anasync defunder a decorator that hides it) now raisesTypeErrorwhen called, after closing the coroutine. Enter the context inside the generator or coroutine instead.
Fixed¶
- The Celery integration could capture a stale tenant or admin header.
django_rls_tenants.contrib.celerycaptured the task's context from the public tenant state alone. Async_to_asynccall cancelled (or timed out) while its function was inside anothertenant_context()/admin_context()copies that block's public values into the calling request, so a task enqueued afterwards carried that block's tenant, or an admin header. Inside a request or block the library set up, capture now reads the library's own record of the active context (the one the GUC affinity guard applies), which such a copy cannot change; a context that has ended but is still current captures nothing. Outside any such scope the public state still counts, so a context built by hand withset_current_tenant_id()/set_rls_context_active()propagates as before. RLSManager.get_queryset()dropped the manager's router hints. Django's manager passesself._hintsto the queryset;RLSManagerdid not, sodb_manager(hints=...)and theinstancehint Django adds for related objects never reached a database router through aTenantQuerySet. They do now.- A request cancelled while
process_requestwas writing the GUCs could leave them on the connection on the hook-based async path (MiddlewareMixin.__acall__, which a subclass overridingprocess_requestorprocess_responsestill uses). asgiref copies the hop'sContextVarvalues at the cancellation, before the hop marks the GUCs set, so the cleanup found nothing to clear. The GUCs are now cleared in a hop queued behind that one, as the native path does. Such a request served inside a live context (a test's block) also no longer takes the block's tenant and strict-mode values off the caller. - Security: under ASGI, an error page could leave a request's tenant context on a
worker thread's connection (#130). Django builds some error responses in a
sync_to_async(..., thread_sensitive=False)hop while the request context is still active: for a URL that does not resolve, a view that returnsNone, or an exception from aprocess_vieworprocess_template_responsehook or from an async-capable middleware listed afterRLSTenantMiddleware. The hop runs on a worker thread with its own database connection and a copy of the request'sContextVars. When that connection opened, theconnection_createdhandler set the request's GUCs on it, and nothing ever cleared them. Worker threads live as long as the server, so a later error page built on the same thread, an anonymous visitor's included, ran with the earlier user's tenant, or with the admin bypass, and could read that data if it queried RLS-protected tables. The handler now applies the GUCs on connect only on the thread that set them, where the middleware's cleanup runs, and clears the RLS GUCs (since #96, every GUC the policies read) on a connection opened on any other thread during the request, which also drops stale values a reused connection may carry. The GUC affinity guard (#41, see "Added") then applies the request's context to each statement such an error page runs, so it sees what the request may see, and takes the context off the worker thread's connection before the first statement run without it, so a later page built on that thread gets only its own request's context (none for an anonymous visitor). Database work a request hands to another thread with a copy of its context (sync_to_async(..., thread_sensitive=False),asyncio.to_thread(), under WSGI too) behaves the same way. Requests whose work stays on the request's thread, such as synchronous WSGI views, are unchanged. Affects ASGI deployments since 1.2.0, which added the handler, whose error handlers, templates or context processors query RLS-protected data. - An anonymous request served inside a context block left the block without its tenant
scope (#41).
RLSTenantMiddleware's cleanup cleared the tenantContextVarand the strict-mode flag also for a request that had set no context, so after an anonymous request served insidetenant_context()oradmin_context()(a test that wraps the test client, say), the rest of the block lost its auto-scope filter andSTRICT_MODEraised. Such a request now leaves a live context it is served in alone; outside one, it still clears values its view left behind. - Security: a connection checked out of Django's connection pool could carry the
previous request's tenant context (#96). Closing a pooled connection
(
OPTIONS["pool"], Django 5.1+, psycopg 3) hands it back topsycopg_pool, which only rolls back an open transaction, so session GUCs set withset_config()(USE_LOCAL_SET=False, the default) survive into the next checkout. The middleware's cleanup normally clears them, but not when it failed or reached another connection: a view that closed its connection before the response ended (a long-lived SSE stream, for one), a crash or a cancelled request. Therequest_finishedsafety net runs after Django's ownclose_old_connectionshas already returned the connection to the pool. Theconnection_createdhandler did nothing when no request context was active, so the next borrower ran with the previous request's tenant or admin bypass when it set no context of its own (an anonymous request, a Celery task, any code outside a context), and with itsextra_bypass_flags(such asrls.is_login_request) in any case, even inside a context, since nothing else clears them, and could read that data.W004never fired, because Django rejects a non-zeroCONN_MAX_AGEwith a pool. Django sendsconnection_createdon every checkout, and the handler now clears every GUC the policies read (the configured tenant and admin GUCs, plus each constraint's GUC names andextra_bypass_flags) on each checkout from a pool on an alias inRLS_TENANTS["DATABASES"], in one statement, with or without a request context, before the new borrower's first query. WithUSE_LOCAL_SET=False, on the thread that set the request's GUCs it then applies them as before. That is one extra round trip per checkout, about one per request. It runs in both GUC modes and even when a poolresetcallback is set. If the clear fails, the checkout fails closed: the connection is discarded and the error raised, so the borrower's query never runs on it. Names that are not custom GUC names (anextra_bypass_flagsentry without a dot) are left out of the statement. The other-thread clear of #130 now uses the same statement, so it also drops bypass flags. Affects every earlier release used with Django's connection pool: withUSE_LOCAL_SET=False, and in either mode for bypass flags, whichbypass_flag(),set_bypass_flag()andrls_context()set session-wide by default. GUCs read only by hand-written policies, or byAddM2MRLSPolicywith non-default names, are not cleared. Also setreset_rls_gucsas the pool'sresetcallback (see "Added"). - The
request_finishedsafety net no longer reopens a closed connection (#96). With Django's connection pool, Django's ownrequest_finishedreceiver has already returned the request's connection when the safety net runs, so its clear checked out another pooled connection, briefly set the admin GUCs on it, and kept it checked out: until the thread's next request under WSGI, and for good under ASGI, where each request runs on its own thread, so each such request cost the pool a connection. A closed connection's session is gone, and a pooled one is cleared on its next checkout, so the safety net now skips both. It only runs when the middleware's own cleanup failed. select_related()with no arguments on Django 6.1 (#114). Django 6.1 deprecates that form, and theRemovedInDjango2028WarningblamedTenantQuerySet.select_relatedinmanagers.pyinstead of the code that made the call. The warning now names the caller's line, and Django-internal callers (the admin changelist withlist_select_related = True, which Django deprecates separately) are not warned twice. No warning on Django 5.2 or 6.0, where the form is not deprecated.MiddlewareMixinimport path (#114). Django 6.2 movesMiddlewareMixintodjango.middlewareand deprecates importing it fromdjango.utils.deprecation(RemovedInDjango2029Warning).RLSTenantMiddlewarenow imports from the new location when it exists and falls back to the old one on Django 5.2–6.1, so the warning never points at this library.@with_rls_contextonasync deffunctions (#39). The decorator now returns anasync defwrapper for coroutine functions, which entersasync_tenant_context()/async_admin_context()around the awaited body. Before, the sync wrapper entered and left the context before the coroutine ran, so the body ran under the caller's context: called from synchronous code insideadmin_context()(for example viaasync_to_sync), a coroutine decorated for a tenant user saw every tenant's rows, and awaited from async code the call failed withSynchronousOnlyOperation.@with_rls_contexttype signature (#39). Type checkers saw the decorator return a union of "the decorated function" and "a decorator", so every call to a decorated function failed type checking, and@with_rls_context(user_param=...)turned it into(*Never, **Never) -> Never. Two@overloads now give each form its exact type: the decorated function keeps its signature and return type (a coroutine forasync def).assert_rls_blocks_without_context()underSTRICT_MODEand inside a context (#126). Its no-context count was an ORM query run with the caller's state. WithSTRICT_MODE=Trueit raisedNoTenantContextErrorbefore reaching PostgreSQL, so suites that run with strict mode could not use it. Insiderls_bypass()orrls_as_tenant()it counted with that context and failed with "RLS may not be enforced". Both counts are now raw SQL on the model's table, and the no-context count runs with the admin and tenant GUCs cleared, then restored (withSET LOCALunderUSE_LOCAL_SET=Trueinsidetransaction.atomic()). The helper works under strict mode, gives the same result inside an admin or tenant context, leaves that context intact, and no longer needs a manager namedobjects. Default-manager filters no longer apply, so every row in the table must be hidden.aassert_rls_blocks_without_context()gets the same fix.for_user()applied the user's GUCs only when the queryset was iterated (#131).TenantQuerySetset them in_fetch_all()alone, socount(),exists(),aggregate(),iterator(),aiterator(),update(),delete(),create()(and with itget_or_create()),bulk_create(),bulk_update(),update_or_create(),explain()and their async variants ran without them. Under a role that RLS applies to, and outside any context, reads, updates and deletes returned zero rows or counts without an error, and inserts were rejected by the policy. Each of these methods now runs its SQL inside one private scope on the connection the SQL uses (the router's write alias for writes), resolved once per call so a router that answers differently each time cannot split the GUCs from the query. Adelete()scope also covers its cascades,SET_NULLupdates and signal handlers.iterator()andaiterator()enter it once per chunk, so it is not held while the loop body runs. A scope reads the two GUCs first: outside a context a tenant user's call now issues six small statements around its query and an admin's four (iteration issued four before, two withSET LOCAL); inside a context that already has the user's values, two.- A
for_user()query overwrote the enclosing context (#131). Evaluating afor_user()queryset insideRLSTenantMiddleware,tenant_context()oradmin_context()cleared the GUCs afterwards, so the rest of the request or block saw no rows. WithUSE_LOCAL_SET=Trueit left thefor_user()user's values set for the rest of the transaction, so later queries ran as that user (as an admin, if it was one). The scope now restores the values it found, writes nothing when they already match, and when a statement aborts the transaction, its own error propagates instead of "current transaction is aborted" from the cleanup. - A context's GUC restore was skipped while Django marked the transaction for
rollback (#131). An exception leaving an inner
atomic(savepoint=False)block (a failingpre_deletereceiver duringdelete(), for example) sets Django's rollback flag, and Django then refuses every query, sorestore_gucs()dropped the restore as if the transaction were aborted. A caller that caught the exception and calledtransaction.set_rollback(False)then committed with the GUCs still set, an admin bypass included. The restore now runs past the flag and puts it back; only an error from PostgreSQL itself is still dropped. This applies to every context manager. STRICT_MODEwithprefetch_related(), cached querysets andaiterator()(#131). The prefetch queries of afor_user()queryset ran with no active context and raisedNoTenantContextError; the scope now marks the context active and sets the auto-scope tenant while its SQL runs. AnyTenantQuerySetwhose results are already cached (including relations prefetched earlier and read in a template) answers iteration,count(),exists()and an orderedfirst()without a query, so those no longer get the strict-mode check.aiterator(), which never goes throughiterator(), is now guarded too.TenantQuerySet.delete()lost Django'squeryset_onlyflag (#131). A manager built withTenantQuerySet.as_manager()orRLSManager.from_queryset()therefore had adelete()method that empties the table in one call, which Django deliberately leaves out. The flag is set again.
1.3.4 - 2026-10-12¶
Fixed¶
RLSTenantMiddlewarecrashed withToken was created in a different Contextunder ASGI (#58). Django runs a sync middleware's hooks throughMiddlewareMixin.__acall__, which callsprocess_requestandprocess_response/process_exceptionin separatesync_to_asynchops, each in its own copy of the request'scontextvars.Context. The tenant state reached the view, but the tokens saved byprocess_requestbelonged to a discarded context, so every authenticated ASGI request ended in a 500 (a view exception was masked by the sameValueError), and the GUC clear after the failed reset was skipped. Cleanup now clears theContextVars to their defaults whenreset()rejects a token from another context (logged atDEBUG), still resets via the token on WSGI, and clears the GUCs in afinallyblock so a reset failure can never skip them. A token reused twice still raises. Cleanup also runs when an async view is cancelled and Django skips the response and exception hooks.USE_LOCAL_SET=Trueremains WSGI-only.
1.3.3 - 2026-10-12¶
Added¶
-
Troubleshooting page and task-oriented index (
docs/troubleshooting.md, #109). One symptom / cause / fix entry per known failure mode, each linking to the guide that owns the explanation: zero rows everywhere, the threeNoTenantContextErrorhints,SET LOCALoutside a transaction, theUSE_LOCAL_SETmiddleware-order and ASGI configuration errors, every active system check (W001/W002, W004, W005, W006, W008, W009) with the retired W003/W007,check_rlsfindings, thecreatesuperuseradmin trap, Celery propagation, PgBouncer GUC leaks, raw SQL scoping, planner behaviour, the open ASGI token bug (#58), and the 1.3.2 fixes (#79, #94, #95, #100, #102) as "upgrade to fix". Covers the older exception-import and token-reuse failures too, and distinguishes streaming transaction requirements, Celery enqueue-time context, and indexable raw SQL predicates from the admin-inclusive form. The docs home page gains an "I want to..." table mapping fifteen common goals to the page that covers them. Both are in the site nav; the README and the Quick Start's "What's Next?" point at the troubleshooting page. -
Example demos for
@rls_task,safe_tenant_sql()andcheck_rls --verbose(#80). The example now ships a broker-free Celery app (demo/celery.py, eager mode) andnotes/tasks.pywith a plain@rls_taskdriven from the Stats page plus arls_require_context = Truetask that fails fast without a context;get_note_statsadds a raw aggregate scoped withsafe_tenant_sql()that the page shows next to the ORM figure it equals for tenant users and admins alike.tests/test_tasks.pyandtests/test_services.pyrun both round-trips against a non-superuser role in CI. The README walks throughcheck_rls --verboseoutput andmanage.py check(W005 / W008 / W009), and the Celery and Raw SQL guides point at the example. -
Performance & Indexing guide (
docs/guides/performance.md, #83). One page for the "what does RLS cost and how do I index for it" question, built from measuredEXPLAIN (ANALYZE, BUFFERS)plans on a 1,000,000-row table as a non-superuser role: why the policy is always a post-scanFilterwhile the ORM'sWHERE tenant_id = Xbecomes theIndex Cond, composite(tenant, ...)indexes viaMeta.indexes, partial-index interaction, the v1.3.0 InitPlan form side by side with the inline form,select_related()/prefetch_related()/ M2M join plans, whichsafe_tenant_sql()form is indexable, how to measure, and a checklist. Linked from the README, the home page, Protecting Models, Raw SQL, Architecture and Security Model.
Changed¶
- Docs no longer claim the planner skips the policy because
current_setting()is notLEAKPROOF. Measured plans show a baretenant_id = current_setting(...)::intpolicy is used as an index condition; what makes the library's policy a post-scan filter is itsCASE WHEN <admin> THEN true ELSE ... ENDshape (and anOR-split into two permissive policies is no better).protecting-models.mdand the new guide state the real cause. The Raw SQL guide now says that only thesafe_tenant_sql(include_admin=False)fragment (or a bind parameter) reaches the index; the admin-inclusive default plans as a sequential scan like the policy. (#83) - Test-coverage backfill for v1.3.0 code paths (#84). Regression guards for wiring
that the existing suite would not have caught if removed: the three error-hint
constants are pinned verbatim (
tests/test_tenants/test_errors.py);check_rls_config()is tested at the orchestrator level (every_check_*helper is aggregated, and W006 / W008 / W009 surface from a broken config);RLSTenantModelAdmin.save_modeltenant-id normalisation is pinned DB-free for int and UUID primary keys; customrls_session_key/rls_tenant_query_paramnames round-trip through selection sync, effective-tenant resolution, the view context and the switcher filter; and Celery canvas propagation (chain / group / chord) checks admin headers and context at every step underadmin_context(), plus the current-task fallback with a tenant header but no admin key. No library code changed. - Example app: migrations are committed and
.env.exampleis gone (#81). The three example apps now ship0001_initial.pyinstead of relying on a runtimemakemigrationsindocker-compose.yml, the README and CI. The notes migration carries theAddConstraint(RLSConstraint(...))operations that emit the RLS policy DDL, which is the most instructive artifact of the demo and was previously only ever generated inside the container.migratealone now brings up the policies, and the CI example job runsmakemigrations --check --dry-runso model/migration drift fails the build. The orphanedexample/.env.exampleis deleted: nothing read it (compose injectsDATABASE_URLand the README's local-development section usesexport). - CI now proves what the README claims (#110). A
test-postgresjob runs the suite on PostgreSQL 15 and 17 with the oldest and newest supported Django (4.2 / 6.0) alongside the PostgreSQL 16 matrix, so "PostgreSQL >= 15" is verified at both ends. Both matrices explicitly select and verify Python/Django versions and disable re-syncing when running pytest, preserving the requested Django version. Every PostgreSQL leg setsRLS_FAIL_ON_SKIPPED=1: apytest_sessionfinishhook intests/conftest.py(mirrored in the example suite) fails the session on any skipped test, and the one runtimepytest.skip()in the suite is now a registeredsuperuser_onlymarker that fails on a plain role and is deselected with-m "not superuser_only". The example job builds the wheel withuv build, asserts it contains onlydjango_rls_tenants/and*.dist-info/(withpy.typed, an__init__.pyin every package directory, the management commands andcontrib.celery), installs it into a clean virtualenv at the locked Django version and runsmanage.py check,makemigrations --checkand the example tests against the installed distribution instead of the source tree. The release workflow gains apreflightjob that refuses a tag whosevX.Y.Zdoes not equalpyproject.toml's version or has no## [X.Y.Z]heading in this file. No library code changed.
Fixed¶
- Docs: the Quick Start and User Integration guides no longer make
UserRLS-protected (#82).class User(AbstractUser, RLSProtectedModel)silently registersRLSM2MConstrainton the inheritedgroupsanduser_permissionsthrough tables, socheck_rlslists them under an extraM2M through tables:section and reports 4 tables, not the 2 the Quick Start promised. Worse, the snippet declared noMeta, soAbstractUser.Metawon and the user table itself had noRLSConstraint:check_rlsfailed on the missing policy instead of printing the documented output. Both guides now use a plainAbstractUserwith a nullabletenantFK (the shape the example project and test app use), the Quick Start output matches a real run, and a new Protecting the User Model section in the Protecting Models guide documents the opt-in case: the auth through tables get RLS,RLSM2MConstrainthas noextra_bypass_flags, and fresh group/permission queries need a tenant or admin context. Protected auth users explicitly inherit the RLSMetaconstraint, the authentication backend covers session reloads viaget_user(), and the guide notes Django's permission cache and superuser behavior. - Docs: the roadmap is reachable from the site nav (#82).
development/roadmap.mdwas committed but missing from the Development section ofmkdocs.yml. It is now listed after the changelog and gains av1.3.x — Patch cycleentry so the public roadmap no longer jumps from v1.3.0 to v1.4.0.
1.3.2 - 2026-10-11¶
Fixed¶
override_settings(RLS_TENANTS=...)was silently ignored (#100).RLSTenantsConfigcachedsettings.RLS_TENANTSon first read and never invalidated it, sooverride_settings(context manager or decorator),SimpleTestCase.settings()and pytest-django'ssettingsfixture had no effect on the library once any setting had been read: a test flippingSTRICT_MODEorUSE_LOCAL_SETran with the original value and passed for the wrong reason. The app config now connects Django'ssetting_changedsignal and reloads the config wheneverRLS_TENANTSchanges (the unknown-key typo check re-runs too). A publicrls_tenants_config.reload()exposes the same hook. The library's own tests and the example suite drop their manual_config_cache = Noneworkarounds.select_related()silently dropped rows on nested nullable paths (#79).TenantQuerySet.select_related("a__b")adds a tenant predicate on the joined RLS-protected table, and for nullable relations it ORs ina__b__isnull=Trueso the join stays a LEFT OUTER JOIN. The nullability check looked up the whole dotted path withget_field(), which never resolves for multi-hop paths, so noisnullclause was added and the predicate forced an INNER JOIN: rows whose chain had aNULLat any hop (a missing intermediate object or a missing leaf) vanished from the result. The path is now walked segment by segment and theisnullescape hatch is added when any hop is nullable. Fully non-null chains keep their INNER JOIN. Single-hop behaviour is unchanged.- Nested context managers now restore the outer database scope under
USE_LOCAL_SET=True(#94).tenant_context(),admin_context(),rls_context()andbypass_flag()skipped the GUC save/restore inSET LOCALmode and relied on the transaction end, so an inner block's scope outlived the block: after an inneradmin_context()exited, the connection keptrls.is_admin = 'true'for the rest of the transaction whileget_current_tenant_id()still reported the outer tenant. Raw SQL,update()/delete()on plain managers and models withoutRLSManagersaw every tenant. All four context managers now read the previous values on entry and re-issue them with the same scope on exit, in both GUC modes. Newdjango_rls_tenants.rls.guc.restore_guc()andrls.context.restore_gucs()are the shared primitives.rls_context()also restores earlier settings when a later setting fails during entry, preventing a partially applied admin bypass from leaking in autocommit mode. - Context managers no longer mask the real database error (#102). Exiting a block
after a failed statement (
IntegrityError,DataError, ...) ran the GUC restore on an aborted transaction, and the resultingInternalError: current transaction is abortedreplaced the original exception (defaultset_configmode;SET LOCALmode was only unaffected because of #94). When the block exits with a pending exception, aDatabaseErrorraised by the restore itself is now logged at DEBUG and swallowed; the transaction or savepoint rollback restores the GUCs. A clean exit still propagates restore failures. RLSTenantMiddlewarecrashed on every authenticated request withUSE_LOCAL_SET=True(#95), including theATOMIC_REQUESTS=Truesetup the connection-pooling guide recommended. Django'sATOMIC_REQUESTSwraps only the view callable, soprocess_requestran in autocommit andSET LOCALraisedRuntimeError: Cannot use SET LOCAL ... outside a transaction. The middleware now owns the transaction whenUSE_LOCAL_SET=True: for authenticated requests it openstransaction.atomic()on every alias inRLS_TENANTS["DATABASES"]around the rest of the chain and the view, so the GUCs cover the whole request and vanish at commit/rollback.ATOMIC_REQUESTSis no longer required (it nests as a savepoint if kept); the defaultUSE_LOCAL_SET=Falsepath is unchanged.- A view that raises rolls the request transaction back; an explicit error response
commits. The rollback is applied when the transaction exits, not in
process_exception, so error handlers can still query after a non-database exception such asHttp404(their writes roll back too). An exception that escapes the middleware chain directly still cleans up the request state. - Exception-handling middleware, and function-based middleware factories, must be
listed before
RLSTenantMiddleware: Django runsprocess_exceptionhooks in reverse order, and a later handler that returns a response would skip the rollback hook and commit partial writes. Unsafe ordering raisesRLSConfigurationErrorwhen the middleware is loaded. - Under ASGI the mode is refused with a clear
RLSConfigurationErrorinstead of the opaqueRuntimeError(native async support is v1.4.0). Async detection no longer relies onMiddlewareMixin.async_mode, which Django 4.2 and 5.0 do not have. - System check
W004no longer recommendsATOMIC_REQUESTS. - New end-to-end tests drive
django.test.Clientthrough the real middleware stack with and withoutATOMIC_REQUESTS, including database-backed error handlers, handled view failures, unsafe middleware ordering and raw-SQL tenant isolation.
- A view that raises rolls the request transaction back; an explicit error response
commits. The rollback is applied when the transaction exits, not in
Removed¶
- System check
W007(USE_LOCAL_SET=TruewithoutATOMIC_REQUESTS) is retired (#95). The combination it warned about is now valid, because the middleware opens its own transaction. Check IDs are not reused; leavingdjango_rls_tenants.W007inSILENCED_SYSTEM_CHECKSis harmless.
1.3.1 - 2026-10-11¶
Added¶
clear_bypass_flag()now acceptsis_local=True, mirroringset_bypass_flag(), so a transaction-scoped (SET LOCAL) bypass flag can be cleared the same way it was set. Keyword-only, defaultFalse; no existing call changes. (#76)
Changed¶
- Example admin now showcases
RLSTenantModelAdmin(#73).example/notes/admin.pyregistersNoteandCategorywith the v1.3.0 mixin instead of a plainadmin.ModelAdminwith a hand-rolledtenantlist filter.seed_demoadds a tenant-scoped staff account (staff/staff1234: Acme Corp,is_staff=True,is_tenant_admin=False, permissions on the notes app) so the auto-scoped changelist and the hidden + auto-assigned tenant FK can be seen at/admin/; the existingadminaccount demonstrates the tenant switcher. Newexample/tests/test_admin.pydrives both paths through the test client. Existing demo volumes gain the missing staff account when the seed command runs again, without resetting their data. - CI now runs
mkdocs build --stricton every pull request (#77), so broken links and stalemkdocstringsreferences fail before merge instead of after. Deploy is still only onmain. - Pre-commit ruff hooks now run
uv run ruff(local hooks, like mypy) so they always use the ruff version pinned inuv.lock-- the same one CI runs (#77). Previously the hook was pinned independently (v0.15.5vs0.15.20in the lockfile) and local autofix/format could disagree with CI. - System check
W005now also flags a database role with theBYPASSRLSattribute, which bypasses every RLS policy exactly like a superuser. It readsrolsuperandrolbypassrlsfrompg_roles(previously onlypg_user.usesuper), names the role and the offending attribute in the message, and recommendsNOSUPERUSER NOBYPASSRLSin the hint (with a ready-to-run, identifier-quotedALTER ROLEfor the current role). Projects already connecting as a plain role see no change. (#99)
Removed¶
- System check
W003(USE_LOCAL_SETwithoutATOMIC_REQUESTSondefault). It only looked atDATABASES['default'], duplicatedW007on single-database projects and fired spuriously whendefaultis not an RLS-managed alias.W007covers every configured alias; nothing to do unless you referenceW003in a custom check, and silencing it viaSILENCED_SYSTEM_CHECKSremains harmless. (#76)
Fixed¶
- The README and the docs landing page now list the v1.3.0 features (tenant-aware
admin, native Celery integration, raw SQL helpers) with links to their guides, and
SECURITY.md"Supported Versions" now includes the current1.3.xseries. (#75) - Middleware cleanup now resets context tokens only once when Django calls both exception and response hooks, preserving the expected 403 for denied admin pages.
- Example app crashed at import (#72):
example/notes/views.pyandexample/tests/test_rls.pyimportedNoTenantContextErrorfrom the package root, which v1.2.1 intentionally stopped exporting. Both now use the canonicalfrom django_rls_tenants.exceptions import NoTenantContextError. A newexample-smokeCI job runs the example'smanage.py check(and test collection) without a database so an import-level break inexample/can no longer ship. RLSM2MConstraintdocstring said FK references are verified viaIN (SELECT ...); the generated policies useEXISTS (SELECT 1 ...). Docstring now matches the DDL. (#76)docs/guides/connection-pooling.mdnow quotes the realW007/W004check messages and hints instead of approximate text. (#76)- Example README (#74): the "Database-level proof" step told you to run
manage.py dbshellinside thewebcontainer, which has nopsqland fails withCommandError. It now connects through thedbcontainer as the non-superuserapprole, with complete host-terminal commands for both theappanddemosuperuser queries. The expected 15-note total is qualified as a freshly seeded database. The features table and project tree also listtests/test_seed_demo.py. - Security guide threat model (#99): "Tenant impersonation via SQL" was listed as
protected against, but any SQL running on the application connection (an injection,
compromised code, a
dbshellwith the app credentials) can callset_config()to change the tenant or admin GUC. The guide now scopes the guarantee to omitted tenant scoping, moves arbitrary SQL execution to "Not Protected Against", and explains that PostgreSQL exempts referential-integrity checks and unique indexes from RLS, so FK targets and per-tenant uniqueness need their own validation. The architecture page no longer claimsFORCE ROW LEVEL SECURITYstops superuser bypass.
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.