-
Notifications
You must be signed in to change notification settings - Fork 994
Expand file tree
/
Copy pathrls_context.py
More file actions
199 lines (154 loc) · 6.23 KB
/
rls_context.py
File metadata and controls
199 lines (154 loc) · 6.23 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
"""
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.SetOrgContext', # After GetProfileAndOrg
...
]
"""
import logging
from django.db import connection
from django.http import JsonResponse
logger = logging.getLogger(__name__)
class SetOrgContext:
"""
Middleware to set PostgreSQL session variable for Row-Level Security.
This sets `app.current_org` to the user's organization ID, which is
used by RLS policies to automatically filter data at the database level.
Security: This provides defense-in-depth. Even if application code
forgets to filter by org, the database will enforce isolation.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
# Set org context before processing request
self._set_org_context(request)
response = self.get_response(request)
# Reset context after request
self._reset_org_context()
return response
def _set_org_context(self, request):
"""
Set the PostgreSQL session variable for RLS.
Args:
request: Django request object with profile attached
"""
if not hasattr(request, "org") or request.org is None:
return
org_id = str(request.org.id)
try:
with connection.cursor() as cursor:
# Set the session variable (is_local=false for session scope)
# Required because Django uses autocommit mode by default
cursor.execute(
"SELECT set_config('app.current_org', %s, false)", [org_id]
)
logger.debug("Set RLS context: app.current_org = %s", org_id)
except Exception as e:
# RLS might not be configured - log but don't fail
logger.debug("Could not set RLS context: %s", e)
def _reset_org_context(self):
"""
Reset the PostgreSQL session variable after request.
Critical to prevent context leakage between requests on pooled connections.
"""
try:
with connection.cursor() as cursor:
cursor.execute("SELECT set_config('app.current_org', '', false)")
except Exception:
pass
class RequireOrgContext:
"""
Stricter middleware that fails if org context is not set.
Use this instead of SetOrgContext when you want to ensure
all requests have proper org context (after RLS is fully enabled).
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/refresh-token/",
"/api/auth/me/",
"/api/auth/switch-org/",
"/api/auth/google/",
"/api/auth/magic-link/request/",
"/api/auth/magic-link/verify/",
"/api/org/",
"/admin/",
"/swagger-ui/",
"/api/schema/",
]
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:
resolve(request.path)
except Resolver404:
return self.get_response(request)
if not hasattr(request, "org") or request.org is None:
return JsonResponse(
{"detail": "Organization context is required. Please login again."},
status=403,
)
# Set org context
self._set_org_context(request)
response = self.get_response(request)
# Reset context
self._reset_org_context()
return response
def _is_exempt(self, path):
"""Check if path is exempt from org context requirement."""
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)
"""