bottleneckcrm/backend/common/rls/pool.py
Ashwin 24390478e0 Refactor notifications handling: replace SSE with polling mechanism
- Removed the `/api/notifications/stream/` endpoint and associated SSE logic.
- Updated `Bell.svelte` and `notifications.svelte.js` to implement polling for notifications every 45 seconds.
- Introduced connection pooling configuration in environment variables documentation.
- Updated production deployment documentation to recommend WSGI over ASGI for improved database connection management.
2026-08-04 00:18:17 +05:30

62 lines
3.2 KiB
Python

"""Connection-pool hooks that keep RLS context from leaking between tenants.
**Why this module exists.** `app.current_org` is set with
``set_config(<var>, <org>, false)``, i.e. **SESSION** scope. That is deliberate:
Django runs in autocommit, so a transaction-local (`true`) setting would vanish
before the next statement in the same request. The cost is that the value
outlives the statement, the transaction, and the request. It is cleared only
because something explicitly clears it.
Without pooling that was survivable: ``CONN_MAX_AGE=0`` closed the backend at
the end of every request, taking the session state with it. With a pool, the
same backend is handed to the next borrower still carrying the previous
tenant's org id, and every RLS policy on the database happily scopes that
borrower's queries to the wrong tenant. psycopg_pool does **not** clean session
state on its own: ``ConnectionPool._reset_connection`` only rolls back an open
transaction. A `SET`/`set_config` survives a rollback when it was made at
session scope outside that transaction.
So the reset below is not a tidiness measure. It is the thing standing between
a pooled deployment and a cross-tenant read.
**This module must not import Django.** ``crm/settings.py`` imports it while
building ``DATABASES``, long before the app registry is ready. It takes a raw
psycopg connection and speaks SQL.
IMPORTANT: this module cannot be exercised by the default test suite, which
runs on SQLite (``crm/test_settings.py``) and has no ``set_config`` at all.
See ``common/tests/test_pool_rls_isolation.py``, which is marked
``postgres_only`` and needs a non-superuser role to mean anything, since a
superuser bypasses RLS entirely.
"""
from . import CONTEXT_VARIABLE
# Set to the empty string rather than RESET/DISCARD. The isolation policies
# generated by `get_enable_policy_sql` compare against
# `NULLIF(current_setting(<var>, true), '')`, so empty and unset both fail
# closed to zero rows. Assigning '' keeps the GUC defined, which means
# `current_setting(<var>, true)` never raises for later readers.
CLEAR_CONTEXT_SQL = f"SELECT set_config('{CONTEXT_VARIABLE}', '', false)"
def reset_rls_context(conn):
"""psycopg_pool ``reset`` callback: clear tenant context before reuse.
Contract, from ``ConnectionPool._reset_connection``: if this function
raises, or leaves the connection in any transaction status other than
IDLE, the pool logs a warning and **discards the connection**. A reset that
silently dirties every connection turns the pool into an expensive way of
opening one connection per request, which looks like pooling doing nothing
rather than like an error. Hence the explicit commit below.
The connection arrives IDLE (the pool rolls back an open transaction before
calling us). Under autocommit the statement stands alone and we stay IDLE.
Without autocommit the statement opens a transaction, so we must commit to
return to IDLE. Commit, not rollback: a session-scoped `set_config` made
inside a transaction is *reverted* by a rollback, which would leave the
stale org id in place and defeat the entire point.
"""
conn.execute(CLEAR_CONTEXT_SQL)
if not conn.autocommit:
conn.commit()