fh-saas 0.9.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fh_saas/__init__.py +1 -0
- fh_saas/_modidx.py +201 -0
- fh_saas/core.py +9 -0
- fh_saas/db_host.py +153 -0
- fh_saas/db_tenant.py +142 -0
- fh_saas/utils_api.py +109 -0
- fh_saas/utils_auth.py +647 -0
- fh_saas/utils_bgtsk.py +112 -0
- fh_saas/utils_blog.py +147 -0
- fh_saas/utils_db.py +151 -0
- fh_saas/utils_email.py +327 -0
- fh_saas/utils_graphql.py +257 -0
- fh_saas/utils_log.py +56 -0
- fh_saas/utils_polars_mapper.py +134 -0
- fh_saas/utils_seo.py +230 -0
- fh_saas/utils_sql.py +320 -0
- fh_saas/utils_sync.py +115 -0
- fh_saas/utils_webhook.py +216 -0
- fh_saas/utils_workflow.py +23 -0
- fh_saas-0.9.5.dist-info/METADATA +274 -0
- fh_saas-0.9.5.dist-info/RECORD +25 -0
- fh_saas-0.9.5.dist-info/WHEEL +5 -0
- fh_saas-0.9.5.dist-info/entry_points.txt +2 -0
- fh_saas-0.9.5.dist-info/licenses/LICENSE +201 -0
- fh_saas-0.9.5.dist-info/top_level.txt +1 -0
fh_saas/utils_auth.py
ADDED
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
"""Multi-user tenant authentication with Google OAuth, CSRF protection, and automatic tenant provisioning."""
|
|
2
|
+
|
|
3
|
+
# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/04_utils_auth.ipynb.
|
|
4
|
+
|
|
5
|
+
# %% auto 0
|
|
6
|
+
__all__ = ['logger', 'ROLE_HIERARCHY', 'DEFAULT_SKIP_AUTH', 'has_min_role', 'get_user_role', 'require_role',
|
|
7
|
+
'invalidate_auth_cache', 'create_auth_beforeware', 'get_google_oauth_client', 'generate_oauth_state',
|
|
8
|
+
'verify_oauth_state', 'create_or_get_global_user', 'get_user_membership', 'verify_membership',
|
|
9
|
+
'provision_new_user', 'create_user_session', 'get_current_user', 'clear_session', 'route_user_after_login',
|
|
10
|
+
'require_tenant_access', 'handle_login_request', 'handle_oauth_callback', 'handle_logout']
|
|
11
|
+
|
|
12
|
+
# %% ../nbs/04_utils_auth.ipynb 2
|
|
13
|
+
from fastsql import *
|
|
14
|
+
from fastcore.utils import *
|
|
15
|
+
from fh_saas.db_host import (
|
|
16
|
+
timestamp, gen_id,
|
|
17
|
+
GlobalUser, TenantCatalog, Membership, HostAuditLog,
|
|
18
|
+
HostDatabase
|
|
19
|
+
)
|
|
20
|
+
from fh_saas.db_tenant import (
|
|
21
|
+
get_or_create_tenant_db,
|
|
22
|
+
init_tenant_core_schema,
|
|
23
|
+
TenantUser
|
|
24
|
+
)
|
|
25
|
+
from fasthtml.oauth import GoogleAppClient, redir_url
|
|
26
|
+
from starlette.responses import RedirectResponse
|
|
27
|
+
import os
|
|
28
|
+
import uuid
|
|
29
|
+
import json
|
|
30
|
+
import logging
|
|
31
|
+
import time
|
|
32
|
+
from dotenv import load_dotenv
|
|
33
|
+
|
|
34
|
+
load_dotenv()
|
|
35
|
+
|
|
36
|
+
# Module-level logger - configured by app via configure_logging()
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
# %% ../nbs/04_utils_auth.ipynb 6
|
|
40
|
+
# Role hierarchy: higher number = more permissions
|
|
41
|
+
ROLE_HIERARCHY = {
|
|
42
|
+
'admin': 3,
|
|
43
|
+
'editor': 2,
|
|
44
|
+
'viewer': 1
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
def has_min_role(user: dict, required_role: str) -> bool:
|
|
48
|
+
"""Check if user meets the minimum role requirement.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
user: User dict with 'role' field (from request.state.user)
|
|
52
|
+
required_role: Minimum role needed ('admin', 'editor', 'viewer')
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
True if user's role >= required_role in hierarchy
|
|
56
|
+
|
|
57
|
+
Example:
|
|
58
|
+
>>> user = {'role': 'editor'}
|
|
59
|
+
>>> has_min_role(user, 'viewer') # True - editor > viewer
|
|
60
|
+
>>> has_min_role(user, 'admin') # False - editor < admin
|
|
61
|
+
"""
|
|
62
|
+
user_role = user.get('role', 'viewer')
|
|
63
|
+
user_level = ROLE_HIERARCHY.get(user_role, 0)
|
|
64
|
+
required_level = ROLE_HIERARCHY.get(required_role, 0)
|
|
65
|
+
return user_level >= required_level
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_user_role(session: dict, tenant_db: 'Database' = None) -> str:
|
|
69
|
+
"""Derive effective role from session and tenant database.
|
|
70
|
+
|
|
71
|
+
Rules:
|
|
72
|
+
1. Tenant owner (session['tenant_role'] == 'owner') → 'admin'
|
|
73
|
+
2. System admin → 'admin'
|
|
74
|
+
3. Otherwise → lookup TenantUser.local_role from tenant DB
|
|
75
|
+
4. Fallback → None (user must be explicitly assigned a role)
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
session: User session dict
|
|
79
|
+
tenant_db: Tenant database connection (optional)
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Effective role string: 'admin', 'editor', 'viewer', or None
|
|
83
|
+
"""
|
|
84
|
+
# Owner is always admin
|
|
85
|
+
if session.get('tenant_role') == 'owner':
|
|
86
|
+
return 'admin'
|
|
87
|
+
|
|
88
|
+
# System admin is always admin
|
|
89
|
+
if session.get('is_sys_admin'):
|
|
90
|
+
return 'admin'
|
|
91
|
+
|
|
92
|
+
# Look up local_role from TenantUser
|
|
93
|
+
if tenant_db:
|
|
94
|
+
user_id = session.get('user_id')
|
|
95
|
+
if user_id:
|
|
96
|
+
try:
|
|
97
|
+
tenant_db.conn.rollback()
|
|
98
|
+
tenant_users = tenant_db.t.core_tenant_users
|
|
99
|
+
all_users = tenant_users()
|
|
100
|
+
matching = [u for u in all_users if u.id == user_id]
|
|
101
|
+
if matching:
|
|
102
|
+
return matching[0].local_role
|
|
103
|
+
except Exception as e:
|
|
104
|
+
logger.warning(f"Failed to lookup TenantUser role: {e}")
|
|
105
|
+
|
|
106
|
+
# No role found - user must be assigned explicitly
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
# %% ../nbs/04_utils_auth.ipynb 9
|
|
110
|
+
from functools import wraps
|
|
111
|
+
from starlette.responses import Response
|
|
112
|
+
|
|
113
|
+
def require_role(min_role: str):
|
|
114
|
+
"""Decorator to protect routes with minimum role requirement.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
min_role: Minimum role required ('admin', 'editor', 'viewer')
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Decorator that checks request.state.user['role']
|
|
121
|
+
Returns 403 Forbidden if user lacks required role
|
|
122
|
+
|
|
123
|
+
Example:
|
|
124
|
+
>>> @app.get('/admin/settings')
|
|
125
|
+
>>> @require_role('admin')
|
|
126
|
+
>>> def admin_settings(request):
|
|
127
|
+
... return "Admin only content"
|
|
128
|
+
|
|
129
|
+
>>> @app.get('/reports')
|
|
130
|
+
>>> @require_role('viewer') # All authenticated users
|
|
131
|
+
>>> def view_reports(request):
|
|
132
|
+
... return "Reports"
|
|
133
|
+
"""
|
|
134
|
+
def decorator(func):
|
|
135
|
+
@wraps(func)
|
|
136
|
+
async def wrapper(request, *args, **kwargs):
|
|
137
|
+
user = getattr(request.state, 'user', None)
|
|
138
|
+
|
|
139
|
+
if not user:
|
|
140
|
+
return Response(
|
|
141
|
+
content="Authentication required",
|
|
142
|
+
status_code=401
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
if not user.get('role'):
|
|
146
|
+
return Response(
|
|
147
|
+
content="No role assigned. Contact your administrator.",
|
|
148
|
+
status_code=403
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
if not has_min_role(user, min_role):
|
|
152
|
+
return Response(
|
|
153
|
+
content=f"Access denied. Required role: {min_role}",
|
|
154
|
+
status_code=403
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# Handle both sync and async route functions
|
|
158
|
+
result = func(request, *args, **kwargs)
|
|
159
|
+
if hasattr(result, '__await__'):
|
|
160
|
+
return await result
|
|
161
|
+
return result
|
|
162
|
+
|
|
163
|
+
return wrapper
|
|
164
|
+
return decorator
|
|
165
|
+
|
|
166
|
+
# %% ../nbs/04_utils_auth.ipynb 12
|
|
167
|
+
# Session cache key
|
|
168
|
+
_AUTH_CACHE_KEY = '_auth_cache'
|
|
169
|
+
|
|
170
|
+
def invalidate_auth_cache(session: dict):
|
|
171
|
+
"""Clear the auth cache from session.
|
|
172
|
+
|
|
173
|
+
Call this when:
|
|
174
|
+
- User role or permissions change
|
|
175
|
+
- User is added/removed from tenant
|
|
176
|
+
- Admin changes user's local_role
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
session: User session dict
|
|
180
|
+
|
|
181
|
+
Example:
|
|
182
|
+
>>> # After admin changes user role
|
|
183
|
+
>>> tenant_user.local_role = 'editor'
|
|
184
|
+
>>> tables['tenant_users'].update(tenant_user)
|
|
185
|
+
>>> invalidate_auth_cache(session) # Force fresh lookup
|
|
186
|
+
"""
|
|
187
|
+
session.pop(_AUTH_CACHE_KEY, None)
|
|
188
|
+
logger.debug("Auth cache invalidated")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _get_cached_auth(session: dict, cache_ttl: int) -> dict | None:
|
|
192
|
+
"""Get cached auth data if valid and not expired.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
session: User session dict
|
|
196
|
+
cache_ttl: Cache time-to-live in seconds
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
Cached auth dict with 'user' and 'tenant_id', or None if expired/missing
|
|
200
|
+
"""
|
|
201
|
+
cache = session.get(_AUTH_CACHE_KEY)
|
|
202
|
+
if not cache:
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
cached_at = cache.get('cached_at', 0)
|
|
206
|
+
if time.time() - cached_at > cache_ttl:
|
|
207
|
+
logger.debug("Auth cache expired")
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
return cache
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _set_auth_cache(session: dict, user: dict, tenant_id: str):
|
|
214
|
+
"""Store auth data in session cache.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
session: User session dict
|
|
218
|
+
user: User dict to cache
|
|
219
|
+
tenant_id: Tenant ID to cache
|
|
220
|
+
"""
|
|
221
|
+
session[_AUTH_CACHE_KEY] = {
|
|
222
|
+
'user': user.copy(),
|
|
223
|
+
'tenant_id': tenant_id,
|
|
224
|
+
'cached_at': time.time()
|
|
225
|
+
}
|
|
226
|
+
logger.debug(f"Auth cache set for user {user.get('user_id')}")
|
|
227
|
+
|
|
228
|
+
# %% ../nbs/04_utils_auth.ipynb 17
|
|
229
|
+
from fasthtml.common import Beforeware
|
|
230
|
+
from typing import Callable, Any
|
|
231
|
+
|
|
232
|
+
# Default URL patterns to skip authentication
|
|
233
|
+
DEFAULT_SKIP_AUTH = [
|
|
234
|
+
r'/login.*',
|
|
235
|
+
r'/logout',
|
|
236
|
+
r'/oauth/callback',
|
|
237
|
+
r'/auth/callback',
|
|
238
|
+
r'/health',
|
|
239
|
+
r'/favicon\.ico',
|
|
240
|
+
r'/static/.*',
|
|
241
|
+
]
|
|
242
|
+
|
|
243
|
+
def create_auth_beforeware(
|
|
244
|
+
redirect_path: str = '/login',
|
|
245
|
+
session_key: str = 'user_id',
|
|
246
|
+
skip: list[str] = None,
|
|
247
|
+
include_defaults: bool = True,
|
|
248
|
+
setup_tenant_db: bool = True,
|
|
249
|
+
schema_init: Callable[[Database], dict[str, Any]] = None,
|
|
250
|
+
session_cache: bool = False,
|
|
251
|
+
session_cache_ttl: int = 300,
|
|
252
|
+
):
|
|
253
|
+
"""Create Beforeware that checks for authenticated session and sets up request.state.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
redirect_path: Where to redirect unauthenticated users
|
|
257
|
+
session_key: Session key for user ID
|
|
258
|
+
skip: List of regex patterns to skip auth
|
|
259
|
+
include_defaults: Include default skip patterns
|
|
260
|
+
setup_tenant_db: Auto-setup tenant database on request.state
|
|
261
|
+
schema_init: Optional callback to initialize tables dict.
|
|
262
|
+
Signature: (tenant_db: Database) -> dict[str, Table]
|
|
263
|
+
Result stored in request.state.tables
|
|
264
|
+
session_cache: Enable caching user dict in session to reduce DB queries.
|
|
265
|
+
Recommended for HTMX-heavy apps. Default: False
|
|
266
|
+
session_cache_ttl: Cache TTL in seconds. Default: 300 (5 minutes)
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
Beforeware instance for FastHTML apps
|
|
270
|
+
|
|
271
|
+
Sets on request.state:
|
|
272
|
+
- user: dict with user_id, email, tenant_id, role, is_owner
|
|
273
|
+
- tenant_id: str
|
|
274
|
+
- tenant_db: Database connection
|
|
275
|
+
- tables: dict of Table objects (if schema_init provided)
|
|
276
|
+
|
|
277
|
+
Example:
|
|
278
|
+
>>> # Basic usage
|
|
279
|
+
>>> beforeware = create_auth_beforeware()
|
|
280
|
+
|
|
281
|
+
>>> # With session caching for HTMX apps
|
|
282
|
+
>>> beforeware = create_auth_beforeware(
|
|
283
|
+
... session_cache=True,
|
|
284
|
+
... session_cache_ttl=300
|
|
285
|
+
... )
|
|
286
|
+
|
|
287
|
+
>>> # With schema initialization
|
|
288
|
+
>>> def get_app_tables(db):
|
|
289
|
+
... return {'users': db.create(User, pk='id')}
|
|
290
|
+
>>> beforeware = create_auth_beforeware(schema_init=get_app_tables)
|
|
291
|
+
"""
|
|
292
|
+
skip_patterns = []
|
|
293
|
+
if include_defaults:
|
|
294
|
+
skip_patterns.extend(DEFAULT_SKIP_AUTH)
|
|
295
|
+
if skip:
|
|
296
|
+
skip_patterns.extend(skip)
|
|
297
|
+
|
|
298
|
+
def check_auth(req, sess):
|
|
299
|
+
if session_key not in sess:
|
|
300
|
+
return RedirectResponse(redirect_path, status_code=303)
|
|
301
|
+
|
|
302
|
+
if setup_tenant_db:
|
|
303
|
+
# Check session cache first (if enabled)
|
|
304
|
+
cache_hit = False
|
|
305
|
+
if session_cache:
|
|
306
|
+
cached = _get_cached_auth(sess, session_cache_ttl)
|
|
307
|
+
if cached:
|
|
308
|
+
cache_hit = True
|
|
309
|
+
user = cached['user']
|
|
310
|
+
req.state.user = user
|
|
311
|
+
req.state.tenant_id = cached['tenant_id']
|
|
312
|
+
|
|
313
|
+
# Still need to create tenant_db connection (lightweight)
|
|
314
|
+
if user.get('tenant_id') and not user.get('is_sys_admin'):
|
|
315
|
+
try:
|
|
316
|
+
req.state.tenant_db = get_or_create_tenant_db(user['tenant_id'])
|
|
317
|
+
except Exception as e:
|
|
318
|
+
logger.error(f"Failed to setup tenant_db from cache: {e}")
|
|
319
|
+
req.state.tenant_db = None
|
|
320
|
+
else:
|
|
321
|
+
req.state.tenant_db = None
|
|
322
|
+
|
|
323
|
+
# Cache miss - do full DB lookup
|
|
324
|
+
if not cache_hit:
|
|
325
|
+
user = get_current_user(sess)
|
|
326
|
+
if user:
|
|
327
|
+
req.state.tenant_id = user.get('tenant_id')
|
|
328
|
+
req.state.tenant_db = None
|
|
329
|
+
|
|
330
|
+
if user.get('tenant_id') and not user.get('is_sys_admin'):
|
|
331
|
+
try:
|
|
332
|
+
host_db = HostDatabase.from_env()
|
|
333
|
+
if verify_membership(host_db, user['user_id'], user['tenant_id']):
|
|
334
|
+
req.state.tenant_db = get_or_create_tenant_db(user['tenant_id'])
|
|
335
|
+
else:
|
|
336
|
+
logger.warning(f"Invalid membership for user {user['user_id']}")
|
|
337
|
+
except Exception as e:
|
|
338
|
+
logger.error(f"Failed to setup tenant_db: {e}")
|
|
339
|
+
|
|
340
|
+
# Derive effective role from session + tenant DB
|
|
341
|
+
role = get_user_role(sess, req.state.tenant_db)
|
|
342
|
+
user['role'] = role
|
|
343
|
+
user['is_owner'] = sess.get('tenant_role') == 'owner'
|
|
344
|
+
req.state.user = user
|
|
345
|
+
|
|
346
|
+
# Update session cache (if enabled)
|
|
347
|
+
if session_cache:
|
|
348
|
+
_set_auth_cache(sess, user, user.get('tenant_id'))
|
|
349
|
+
|
|
350
|
+
# Auto-initialize tables if schema_init provided
|
|
351
|
+
if schema_init and req.state.tenant_db:
|
|
352
|
+
try:
|
|
353
|
+
req.state.tables = schema_init(req.state.tenant_db)
|
|
354
|
+
except Exception as e:
|
|
355
|
+
logger.error(f"Failed to initialize schema: {e}")
|
|
356
|
+
req.state.tables = {}
|
|
357
|
+
else:
|
|
358
|
+
req.state.tables = {}
|
|
359
|
+
|
|
360
|
+
return Beforeware(check_auth, skip=skip_patterns)
|
|
361
|
+
|
|
362
|
+
# %% ../nbs/04_utils_auth.ipynb 21
|
|
363
|
+
def get_google_oauth_client():
|
|
364
|
+
"""Initialize Google OAuth client with credentials from environment."""
|
|
365
|
+
client_id = os.getenv('GOOGLE_CLIENT_ID')
|
|
366
|
+
client_secret = os.getenv('GOOGLE_CLIENT_SECRET')
|
|
367
|
+
|
|
368
|
+
if not client_id or not client_secret:
|
|
369
|
+
raise ValueError("Missing GOOGLE_CLIENT_ID or GOOGLE_CLIENT_SECRET in .env")
|
|
370
|
+
|
|
371
|
+
return GoogleAppClient(client_id=client_id, client_secret=client_secret)
|
|
372
|
+
|
|
373
|
+
# %% ../nbs/04_utils_auth.ipynb 24
|
|
374
|
+
def generate_oauth_state():
|
|
375
|
+
"""Generate cryptographically secure random state token for CSRF protection."""
|
|
376
|
+
return uuid.uuid4().hex
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def verify_oauth_state(session: dict, callback_state: str):
|
|
380
|
+
"""Verify OAuth callback state matches stored session state (CSRF protection)."""
|
|
381
|
+
stored_state = session.get('oauth_state')
|
|
382
|
+
|
|
383
|
+
if not stored_state:
|
|
384
|
+
raise ValueError("CSRF validation failed: No state in session")
|
|
385
|
+
|
|
386
|
+
if stored_state != callback_state:
|
|
387
|
+
raise ValueError("CSRF validation failed: State mismatch")
|
|
388
|
+
|
|
389
|
+
session.pop('oauth_state', None) # One-time use
|
|
390
|
+
|
|
391
|
+
# %% ../nbs/04_utils_auth.ipynb 28
|
|
392
|
+
def create_or_get_global_user(host_db: HostDatabase, oauth_id: str, email: str, oauth_info: dict = None):
|
|
393
|
+
"""Create or retrieve GlobalUser from host database."""
|
|
394
|
+
try:
|
|
395
|
+
host_db.rollback()
|
|
396
|
+
all_users = host_db.global_users()
|
|
397
|
+
existing = [u for u in all_users if u.oauth_id == oauth_id]
|
|
398
|
+
|
|
399
|
+
if existing:
|
|
400
|
+
user = existing[0]
|
|
401
|
+
user.last_login = timestamp()
|
|
402
|
+
host_db.global_users.update(user)
|
|
403
|
+
logger.info(f'User login: {email}', extra={'user_id': user.id, 'email': email})
|
|
404
|
+
return user
|
|
405
|
+
|
|
406
|
+
new_user = GlobalUser(
|
|
407
|
+
id=gen_id(),
|
|
408
|
+
email=email,
|
|
409
|
+
oauth_id=oauth_id,
|
|
410
|
+
created_at=timestamp(),
|
|
411
|
+
last_login=timestamp()
|
|
412
|
+
)
|
|
413
|
+
host_db.global_users.insert(new_user)
|
|
414
|
+
logger.info(f'New user created: {email}', extra={'user_id': new_user.id, 'email': email})
|
|
415
|
+
return new_user
|
|
416
|
+
|
|
417
|
+
except Exception as e:
|
|
418
|
+
host_db.rollback()
|
|
419
|
+
logger.error(f'Failed to create/get user {email}: {e}', exc_info=True)
|
|
420
|
+
raise
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def get_user_membership(host_db: HostDatabase, user_id: str):
|
|
424
|
+
"""Get single active membership for user."""
|
|
425
|
+
host_db.rollback()
|
|
426
|
+
all_memberships = host_db.memberships()
|
|
427
|
+
active = [m for m in all_memberships if m.user_id == user_id and m.is_active]
|
|
428
|
+
return active[0] if active else None
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def verify_membership(host_db: HostDatabase, user_id: str, tenant_id: str) -> bool:
|
|
432
|
+
"""Verify user has active membership for specific tenant."""
|
|
433
|
+
host_db.rollback()
|
|
434
|
+
all_memberships = host_db.memberships()
|
|
435
|
+
valid = [m for m in all_memberships if m.user_id == user_id and m.tenant_id == tenant_id and m.is_active]
|
|
436
|
+
return len(valid) > 0
|
|
437
|
+
|
|
438
|
+
# %% ../nbs/04_utils_auth.ipynb 33
|
|
439
|
+
def provision_new_user(host_db: HostDatabase, global_user: GlobalUser) -> str:
|
|
440
|
+
"""Auto-provision new tenant for first-time user."""
|
|
441
|
+
tenant_id = gen_id()
|
|
442
|
+
username = global_user.email.split('@')[0]
|
|
443
|
+
tenant_name = f"{username}'s Workspace"
|
|
444
|
+
tenant_db = None
|
|
445
|
+
|
|
446
|
+
try:
|
|
447
|
+
logger.info(f'Starting tenant provisioning for {global_user.email}',
|
|
448
|
+
extra={'tenant_id': tenant_id, 'user_id': global_user.id})
|
|
449
|
+
|
|
450
|
+
# Create physical tenant database and register in catalog
|
|
451
|
+
tenant_db = get_or_create_tenant_db(tenant_id, tenant_name)
|
|
452
|
+
|
|
453
|
+
# Initialize core tenant schema
|
|
454
|
+
core_tables = init_tenant_core_schema(tenant_db)
|
|
455
|
+
|
|
456
|
+
# Create TenantUser profile
|
|
457
|
+
tenant_user = TenantUser(
|
|
458
|
+
id=global_user.id,
|
|
459
|
+
display_name=username,
|
|
460
|
+
local_role='admin',
|
|
461
|
+
created_at=timestamp()
|
|
462
|
+
)
|
|
463
|
+
core_tables['tenant_users'].insert(tenant_user)
|
|
464
|
+
tenant_db.conn.commit() # Commit tenant changes
|
|
465
|
+
|
|
466
|
+
# Create membership in host database
|
|
467
|
+
membership = Membership(
|
|
468
|
+
id=gen_id(),
|
|
469
|
+
user_id=global_user.id,
|
|
470
|
+
tenant_id=tenant_id,
|
|
471
|
+
profile_id=global_user.id,
|
|
472
|
+
role='owner',
|
|
473
|
+
created_at=timestamp()
|
|
474
|
+
)
|
|
475
|
+
host_db.memberships.insert(membership)
|
|
476
|
+
|
|
477
|
+
# Log provisioning event
|
|
478
|
+
audit_log = HostAuditLog(
|
|
479
|
+
id=gen_id(),
|
|
480
|
+
actor_user_id=global_user.id,
|
|
481
|
+
event_type='tenant_provisioned',
|
|
482
|
+
target_id=tenant_id,
|
|
483
|
+
details=json.dumps({'tenant_name': tenant_name, 'plan_tier': 'free', 'user_email': global_user.email}),
|
|
484
|
+
created_at=timestamp()
|
|
485
|
+
)
|
|
486
|
+
host_db.audit_logs.insert(audit_log)
|
|
487
|
+
|
|
488
|
+
host_db.commit()
|
|
489
|
+
logger.info(f'Tenant provisioned: {tenant_name}',
|
|
490
|
+
extra={'tenant_id': tenant_id, 'tenant_name': tenant_name, 'user_id': global_user.id})
|
|
491
|
+
return tenant_id
|
|
492
|
+
|
|
493
|
+
except Exception as e:
|
|
494
|
+
host_db.rollback()
|
|
495
|
+
if tenant_db:
|
|
496
|
+
try:
|
|
497
|
+
tenant_db.conn.rollback()
|
|
498
|
+
except Exception:
|
|
499
|
+
pass
|
|
500
|
+
logger.error(f'Tenant provisioning failed for {global_user.email}: {e}',
|
|
501
|
+
extra={'tenant_id': tenant_id, 'user_id': global_user.id}, exc_info=True)
|
|
502
|
+
raise Exception(f"Failed to provision tenant for {global_user.email}: {str(e)}") from e
|
|
503
|
+
finally:
|
|
504
|
+
# Always close tenant_db connection to prevent connection leaks
|
|
505
|
+
if tenant_db:
|
|
506
|
+
try:
|
|
507
|
+
tenant_db.conn.close()
|
|
508
|
+
tenant_db.engine.dispose()
|
|
509
|
+
except Exception:
|
|
510
|
+
pass
|
|
511
|
+
|
|
512
|
+
# %% ../nbs/04_utils_auth.ipynb 36
|
|
513
|
+
def create_user_session(session: dict, global_user: GlobalUser, membership: Membership):
|
|
514
|
+
"""Create authenticated session after successful OAuth login."""
|
|
515
|
+
session['user_id'] = global_user.id
|
|
516
|
+
session['email'] = global_user.email
|
|
517
|
+
session['tenant_id'] = membership.tenant_id
|
|
518
|
+
session['tenant_role'] = membership.role
|
|
519
|
+
session['is_sys_admin'] = global_user.is_sys_admin
|
|
520
|
+
session['login_at'] = timestamp()
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def get_current_user(session: dict) -> dict | None:
|
|
524
|
+
"""Extract current user info from session.
|
|
525
|
+
|
|
526
|
+
Returns:
|
|
527
|
+
dict with keys: user_id, email, tenant_id, tenant_role, is_sys_admin
|
|
528
|
+
|
|
529
|
+
Note:
|
|
530
|
+
The 'role' and 'is_owner' fields are added by create_auth_beforeware
|
|
531
|
+
after deriving the effective role from TenantUser.local_role.
|
|
532
|
+
Access via request.state.user['role'] in routes.
|
|
533
|
+
"""
|
|
534
|
+
if 'user_id' not in session:
|
|
535
|
+
return None
|
|
536
|
+
|
|
537
|
+
return {
|
|
538
|
+
'user_id': session.get('user_id'),
|
|
539
|
+
'email': session.get('email'),
|
|
540
|
+
'tenant_id': session.get('tenant_id'),
|
|
541
|
+
'tenant_role': session.get('tenant_role'),
|
|
542
|
+
'is_sys_admin': session.get('is_sys_admin', False)
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def clear_session(session: dict):
|
|
547
|
+
"""Clear all session data (logout)."""
|
|
548
|
+
session.clear()
|
|
549
|
+
|
|
550
|
+
# %% ../nbs/04_utils_auth.ipynb 41
|
|
551
|
+
def route_user_after_login(global_user: GlobalUser, membership: Membership = None) -> str:
|
|
552
|
+
"""Determine redirect URL based on user type and membership."""
|
|
553
|
+
if global_user.is_sys_admin:
|
|
554
|
+
return '/admin/dashboard'
|
|
555
|
+
if membership:
|
|
556
|
+
return '/dashboard'
|
|
557
|
+
raise ValueError(f"User {global_user.email} has no membership")
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def require_tenant_access(request_or_session):
|
|
561
|
+
"""Get tenant database with membership validation."""
|
|
562
|
+
# Check if request.state.tenant_db already set by beforeware
|
|
563
|
+
if hasattr(request_or_session, 'state'):
|
|
564
|
+
if hasattr(request_or_session.state, 'tenant_db') and request_or_session.state.tenant_db:
|
|
565
|
+
return request_or_session.state.tenant_db
|
|
566
|
+
session = getattr(request_or_session, 'session', {})
|
|
567
|
+
else:
|
|
568
|
+
session = request_or_session
|
|
569
|
+
|
|
570
|
+
user = get_current_user(session)
|
|
571
|
+
if not user:
|
|
572
|
+
raise ValueError("Authentication required")
|
|
573
|
+
|
|
574
|
+
host_db = HostDatabase.from_env()
|
|
575
|
+
|
|
576
|
+
if not verify_membership(host_db, user['user_id'], user['tenant_id']):
|
|
577
|
+
raise PermissionError(f"Access denied for user {user['user_id']} to tenant {user['tenant_id']}")
|
|
578
|
+
|
|
579
|
+
return get_or_create_tenant_db(user['tenant_id'])
|
|
580
|
+
|
|
581
|
+
# %% ../nbs/04_utils_auth.ipynb 45
|
|
582
|
+
def handle_login_request(request, session):
|
|
583
|
+
"""Generate Google OAuth URL with CSRF state protection."""
|
|
584
|
+
logger.debug('Login request initiated')
|
|
585
|
+
|
|
586
|
+
# Generate CSRF state token
|
|
587
|
+
state = generate_oauth_state()
|
|
588
|
+
session['oauth_state'] = state
|
|
589
|
+
|
|
590
|
+
# Get OAuth client and generate login link
|
|
591
|
+
client = get_google_oauth_client()
|
|
592
|
+
redirect_uri = redir_url(request, '/auth/callback')
|
|
593
|
+
login_link = client.login_link(redirect_uri=redirect_uri, state=state)
|
|
594
|
+
|
|
595
|
+
return login_link
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def handle_oauth_callback(code: str, state: str, request, session):
|
|
599
|
+
"""Complete OAuth flow: CSRF verify → user info → provision → session → redirect."""
|
|
600
|
+
logger.debug('OAuth callback received')
|
|
601
|
+
|
|
602
|
+
# Step 1: CSRF validation (CRITICAL - must be first)
|
|
603
|
+
verify_oauth_state(session, state)
|
|
604
|
+
|
|
605
|
+
# Step 2: Exchange authorization code for user info
|
|
606
|
+
client = get_google_oauth_client()
|
|
607
|
+
redirect_uri = redir_url(request, '/auth/callback')
|
|
608
|
+
user_info = client.retr_info(code, redirect_uri)
|
|
609
|
+
|
|
610
|
+
# Step 3: Get host database instance (singleton - no need to close)
|
|
611
|
+
host_db = HostDatabase.from_env()
|
|
612
|
+
|
|
613
|
+
# Step 4: Create or get GlobalUser
|
|
614
|
+
oauth_id = user_info[client.id_key] # Google 'sub' field
|
|
615
|
+
email = user_info.get('email', '')
|
|
616
|
+
global_user = create_or_get_global_user(host_db, oauth_id, email, user_info)
|
|
617
|
+
|
|
618
|
+
# Step 5: Check for existing membership
|
|
619
|
+
membership = get_user_membership(host_db, global_user.id)
|
|
620
|
+
|
|
621
|
+
# Step 6: Auto-provision if new user (no membership)
|
|
622
|
+
if not membership and not global_user.is_sys_admin:
|
|
623
|
+
tenant_id = provision_new_user(host_db, global_user)
|
|
624
|
+
membership = get_user_membership(host_db, global_user.id)
|
|
625
|
+
|
|
626
|
+
# Step 7: Create session (skip for sys admin - no tenant)
|
|
627
|
+
if membership:
|
|
628
|
+
create_user_session(session, global_user, membership)
|
|
629
|
+
else:
|
|
630
|
+
# System admin - minimal session
|
|
631
|
+
session['user_id'] = global_user.id
|
|
632
|
+
session['email'] = global_user.email
|
|
633
|
+
session['is_sys_admin'] = True
|
|
634
|
+
session['login_at'] = timestamp()
|
|
635
|
+
|
|
636
|
+
# Step 8: Route to appropriate dashboard
|
|
637
|
+
redirect_url = route_user_after_login(global_user, membership)
|
|
638
|
+
logger.info(f'OAuth complete, redirecting to {redirect_url}', extra={'email': email})
|
|
639
|
+
return RedirectResponse(redirect_url, status_code=303)
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def handle_logout(session):
|
|
643
|
+
"""Clear session and redirect to login page."""
|
|
644
|
+
email = session.get('email', 'unknown')
|
|
645
|
+
clear_session(session)
|
|
646
|
+
logger.info(f'User logged out: {email}')
|
|
647
|
+
return RedirectResponse('/login', status_code=303)
|