bottleneckcrm/backend/common/middleware/rls_context.py
vickytechkey 0a9020d03b
All checks were successful
Deploy Beta (NATIVE) / deploy (push) Successful in 1m48s
feat: Add full A-to-Z Seller and Customer management to Django-CRM
2026-10-24 16:29:51 +05:30

264 lines
12 KiB
Python

"""
Database middleware for Row-Level Security (RLS) context.
This middleware sets the PostgreSQL session variable `app.current_org`
which is used by RLS policies to filter data at the database level.
Enable RLS policies after this middleware is in place:
ALTER TABLE leads ENABLE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON leads
USING (org_id = current_setting('app.current_org', true)::uuid);
Usage in settings.py:
MIDDLEWARE = [
...
'common.middleware.get_company.GetProfileAndOrg',
'common.middleware.rls_context.RequireOrgContext', # After GetProfileAndOrg
...
]
"""
import logging
from django.db import connection
from django.http import JsonResponse
from common.modules import route_refusal
logger = logging.getLogger(__name__)
class RequireOrgContext:
"""
Sets `app.current_org` for the request and refuses a request with no org
context, except on the exempt paths below.
Usage in settings.py:
MIDDLEWARE = [
...
'common.middleware.get_company.GetProfileAndOrg',
'common.middleware.rls_context.RequireOrgContext',
...
]
"""
# Paths that don't require org context
EXEMPT_PATHS = [
"/api/auth/login/",
"/api/auth/refresh-token/",
"/api/auth/me/",
"/api/auth/switch-org/",
"/api/auth/google/",
"/api/auth/magic-link/request/",
"/api/auth/magic-link/verify/",
"/api/auth/magic-link/verify-code/",
"/api/org/",
"/api/sellers/",
"/api/customers/",
"/admin/",
"/swagger-ui/",
"/api/schema/",
# Public CSAT survey link (Tier 2 csat). Anonymous, sets RLS
# context manually inside the view from the survey's own org_id.
"/api/public/csat/",
# Client portal: the invoice and estimate links emailed to customers.
# Anonymous by design: the view authorises on `public_token` alone
# (see invoices/public_views.py) and never reads request.user or
# request.org, so requiring org context here rejected every customer
# who clicked a link. Listed as two specific prefixes rather than a
# blanket "/api/public/" so that anything mounted there in future has
# to opt in deliberately.
#
# NOTE: this restores reachability, not readability. `_set_org_context`
# returns early when request.org is None, so these requests run with
# `app.current_org` empty, and the isolation policy from
# `get_enable_policy_sql` is `org_id::text = NULLIF(current_setting(
# 'app.current_org', true), '')`, which matches nothing when empty.
# Verified as `crm_user` (non-superuser, no BYPASSRLS): invoice,
# estimate and csat_survey all return 0 rows with empty context. So on
# a correctly-configured production database these endpoints now
# answer 404 rather than 403 until the view can resolve the org from
# the token before querying. See docs/PORTAL_RLS.md.
"/api/public/invoice/",
"/api/public/estimate/",
# Public web forms (issue #634). Anonymous by design: a form embedded
# on a customer's website has no JWT and no org claim.
#
# Unlike the invoice and estimate entries above, these views DO resolve
# the org before they query. It is the first path segment after this
# prefix, and `webforms/public_views.py:load_form` calls
# `set_rls_context(org_id)` before the form lookup, so these read
# correctly under a non-superuser role rather than answering 404 the
# way the NOTE above describes.
#
# Prefix-matched, so this covers the three routes under
# `/api/public/forms/` and nothing else. The org id sits AFTER the
# fixed prefix for that reason; see webforms/public_urls.py.
"/api/public/forms/",
# Public help center (G6). Anonymous by design: search engines and
# visitors read it with no credential. The views take the org from the
# slug after this prefix and set the RLS context before any org-scoped
# read, like the web form entry above. Prefix-matched, so this covers
# `/api/public/help/<slug>/...` and nothing else under /api/public/.
"/api/public/help/",
# Task calendar feed (G14). Anonymous by design: a calendar app polls
# it with no credential but the token in the path. The view resolves
# the profile and org from the token (`calendar_feed_token` has no RLS
# policy, like `personal_access_token`) and sets the context before it
# reads `task`. See common/views/calendar_feed_views.py.
"/api/public/calendar/",
# Customer portal sign-in. Anonymous by design: the caller has no org
# claim yet, so the view takes the org from the URL and sets the RLS
# context itself before it reads `contacts`.
#
# This is the login prefix ONLY, and it must stay that way. Adding the
# bare "/api/portal/" here would strip tenant-context enforcement from
# every authenticated portal endpoint in one line, because this list is
# prefix-matched. That is the same trap already recorded for
# /api/packs/ in EXEMPT_EXACT_PATHS below. The authenticated portal
# endpoints resolve their org in GetProfileAndOrg and are meant to be
# checked here like everything else.
"/api/portal/login/",
]
# Paths exempt on an EXACT match only, never prefix-matched like
# EXEMPT_PATHS above. Mirrors the exact-match pattern already used in
# common.middleware.get_company.GetProfileAndOrg.process_request
# (`if request.path in auth_skip_paths`).
#
# GET /api/packs/ is static content read from JSON files in the repo,
# byte-identical for every tenant, no tenant data whatsoever. It backs
# the pack chooser on the org-creation page, where a user creating their
# *first* org has no org_id claim yet, so RequireOrgContext would 403 the
# exact audience the page serves.
#
# This MUST stay an exact match, not a prefix: POST
# /api/packs/<pack_id>/apply/ (writes org-wide config, ADMIN only) and
# DELETE /api/packs/sample-data/ (deletes records, ADMIN only) both start
# with "/api/packs/" too. Adding "/api/packs/" to the prefix-matched
# EXEMPT_PATHS list instead of here would silently strip tenant-context
# enforcement from those write/destroy endpoints. Do not move it there.
# POST /api/auth/logout/ revokes the refresh token it is handed. It is
# reached most often when the access token has already expired, so there is
# no org claim to require, and demanding one would fail sign-out in exactly
# the case sign-out matters. Exact match: the view reads nothing but the
# token in the body, and no sub-path should inherit this.
#
# GET /healthz/ is an unauthenticated liveness probe (load balancers, uptime
# monitors, container orchestrators). It renders a static template and reads
# no tenant data, so there is no org context to require. Gating it makes every
# health check return 403 and the service read as down. Exact match: nothing
# should hang off /healthz/.
EXEMPT_EXACT_PATHS = [
"/healthz/",
"/api/packs/",
"/api/auth/logout/",
]
# Routes exempt by their resolved, namespaced URL name, for an anonymous
# route whose path carries an id and so cannot be an exact path, and which
# sits beside authenticated routes that a prefix would also catch.
#
# The inbound email webhook (`/api/cases/inbound/<mailbox_id>/`) is POSTed
# by AWS SNS with no credential, so it has no org claim to require, and
# without this entry every delivery answered 403. The view authenticates
# the delivery itself (SNS signature plus the mailbox's TopicArn pin) and
# resolves the org from the unscoped `PortalAccessToken` lookup before it
# sets the RLS context and reads `inbound_mailbox`. See
# cases/inbound_views.py.
#
# By name, not by prefix: "/api/cases/" or "/api/cases/inbound/" in
# EXEMPT_PATHS would reach further than this one route as soon as anything
# else is mounted there, and the admin mailbox endpoints under /api/cases/
# must keep requiring org context like everything else.
EXEMPT_VIEW_NAMES = frozenset({"common_urls:api_cases:inbound_webhook"})
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
# Check if path requires org context
if not self._is_exempt(request.path):
# Skip check for URLs that don't resolve (let Django return 404)
from django.urls import resolve
from django.urls.exceptions import Resolver404
try:
match = resolve(request.path)
except Resolver404:
return self.get_response(request)
if match.view_name not in self.EXEMPT_VIEW_NAMES and (
not hasattr(request, "org") or request.org is None
):
return JsonResponse(
{"detail": "Organization context is required. Please login again."},
status=403,
)
refusal = route_refusal(request, match)
if refusal is not None:
return refusal
# Set org context
self._set_org_context(request)
# The reset is in a finally because
# `app.current_org` is SESSION-scoped and survives an exception. On a
# pooled or otherwise reused connection, skipping it leaks this
# tenant's context to whoever borrows the connection next.
try:
return self.get_response(request)
finally:
self._reset_org_context()
def _is_exempt(self, path):
"""Check if path is exempt from org context requirement."""
if path in self.EXEMPT_EXACT_PATHS:
return True
return any(path.startswith(exempt) for exempt in self.EXEMPT_PATHS)
def _set_org_context(self, request):
"""Set PostgreSQL session variable (session scope for autocommit mode)."""
if not hasattr(request, "org") or request.org is None:
return
org_id = str(request.org.id)
try:
with connection.cursor() as cursor:
cursor.execute(
"SELECT set_config('app.current_org', %s, false)", [org_id]
)
except Exception as e:
logger.warning("Failed to set RLS context: %s", e)
def _reset_org_context(self):
"""Reset PostgreSQL session variable after request."""
try:
with connection.cursor() as cursor:
cursor.execute("SELECT set_config('app.current_org', '', false)")
except Exception:
pass
# SQL to enable RLS on all org-scoped tables
RLS_SETUP_SQL = """
-- Enable RLS on main tables
-- Run this after all org-scoped tables are identified
-- Example for leads table:
-- ALTER TABLE lead ENABLE ROW LEVEL SECURITY;
-- ALTER TABLE lead FORCE ROW LEVEL SECURITY; -- Apply to table owner too
-- CREATE POLICY org_isolation ON lead
-- USING (org_id = NULLIF(current_setting('app.current_org', true), '')::uuid);
-- Tables that need RLS policies:
-- lead, accounts, contacts, opportunity, cases, tasks, invoices,
-- comment, attachments, document, teams, activity, tags, address,
-- api_settings, board, board_column, board_task, board_member
-- Note: Use NULLIF to handle empty string when context is not set
-- This makes the policy return no rows when context is not set (fail-safe)
"""