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/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)