@weirdfingers/baseboards 0.2.0

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.
Files changed (139) hide show
  1. package/README.md +191 -0
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.js +887 -0
  4. package/dist/index.js.map +1 -0
  5. package/package.json +64 -0
  6. package/templates/README.md +120 -0
  7. package/templates/api/.env.example +62 -0
  8. package/templates/api/Dockerfile +32 -0
  9. package/templates/api/README.md +132 -0
  10. package/templates/api/alembic/env.py +106 -0
  11. package/templates/api/alembic/script.py.mako +28 -0
  12. package/templates/api/alembic/versions/20250101_000000_initial_schema.py +448 -0
  13. package/templates/api/alembic/versions/20251022_174729_remove_provider_name_from_generations.py +71 -0
  14. package/templates/api/alembic/versions/20251023_165852_switch_to_declarative_base_and_mapping.py +411 -0
  15. package/templates/api/alembic/versions/2025925_62735_add_seed_data_for_default_tenant.py +85 -0
  16. package/templates/api/alembic.ini +36 -0
  17. package/templates/api/config/generators.yaml +25 -0
  18. package/templates/api/config/storage_config.yaml +26 -0
  19. package/templates/api/docs/ADDING_GENERATORS.md +409 -0
  20. package/templates/api/docs/GENERATORS_API.md +502 -0
  21. package/templates/api/docs/MIGRATIONS.md +472 -0
  22. package/templates/api/docs/storage_providers.md +337 -0
  23. package/templates/api/pyproject.toml +165 -0
  24. package/templates/api/src/boards/__init__.py +10 -0
  25. package/templates/api/src/boards/api/app.py +171 -0
  26. package/templates/api/src/boards/api/auth.py +75 -0
  27. package/templates/api/src/boards/api/endpoints/__init__.py +3 -0
  28. package/templates/api/src/boards/api/endpoints/jobs.py +76 -0
  29. package/templates/api/src/boards/api/endpoints/setup.py +505 -0
  30. package/templates/api/src/boards/api/endpoints/sse.py +129 -0
  31. package/templates/api/src/boards/api/endpoints/storage.py +74 -0
  32. package/templates/api/src/boards/api/endpoints/tenant_registration.py +296 -0
  33. package/templates/api/src/boards/api/endpoints/webhooks.py +13 -0
  34. package/templates/api/src/boards/auth/__init__.py +15 -0
  35. package/templates/api/src/boards/auth/adapters/__init__.py +20 -0
  36. package/templates/api/src/boards/auth/adapters/auth0.py +220 -0
  37. package/templates/api/src/boards/auth/adapters/base.py +73 -0
  38. package/templates/api/src/boards/auth/adapters/clerk.py +172 -0
  39. package/templates/api/src/boards/auth/adapters/jwt.py +122 -0
  40. package/templates/api/src/boards/auth/adapters/none.py +102 -0
  41. package/templates/api/src/boards/auth/adapters/oidc.py +284 -0
  42. package/templates/api/src/boards/auth/adapters/supabase.py +110 -0
  43. package/templates/api/src/boards/auth/context.py +35 -0
  44. package/templates/api/src/boards/auth/factory.py +115 -0
  45. package/templates/api/src/boards/auth/middleware.py +221 -0
  46. package/templates/api/src/boards/auth/provisioning.py +129 -0
  47. package/templates/api/src/boards/auth/tenant_extraction.py +278 -0
  48. package/templates/api/src/boards/cli.py +354 -0
  49. package/templates/api/src/boards/config.py +116 -0
  50. package/templates/api/src/boards/database/__init__.py +7 -0
  51. package/templates/api/src/boards/database/cli.py +110 -0
  52. package/templates/api/src/boards/database/connection.py +252 -0
  53. package/templates/api/src/boards/database/models.py +19 -0
  54. package/templates/api/src/boards/database/seed_data.py +182 -0
  55. package/templates/api/src/boards/dbmodels/__init__.py +455 -0
  56. package/templates/api/src/boards/generators/__init__.py +57 -0
  57. package/templates/api/src/boards/generators/artifacts.py +53 -0
  58. package/templates/api/src/boards/generators/base.py +140 -0
  59. package/templates/api/src/boards/generators/implementations/__init__.py +12 -0
  60. package/templates/api/src/boards/generators/implementations/audio/__init__.py +3 -0
  61. package/templates/api/src/boards/generators/implementations/audio/whisper.py +66 -0
  62. package/templates/api/src/boards/generators/implementations/image/__init__.py +3 -0
  63. package/templates/api/src/boards/generators/implementations/image/dalle3.py +93 -0
  64. package/templates/api/src/boards/generators/implementations/image/flux_pro.py +85 -0
  65. package/templates/api/src/boards/generators/implementations/video/__init__.py +3 -0
  66. package/templates/api/src/boards/generators/implementations/video/lipsync.py +70 -0
  67. package/templates/api/src/boards/generators/loader.py +253 -0
  68. package/templates/api/src/boards/generators/registry.py +114 -0
  69. package/templates/api/src/boards/generators/resolution.py +515 -0
  70. package/templates/api/src/boards/generators/testmods/class_gen.py +34 -0
  71. package/templates/api/src/boards/generators/testmods/import_side_effect.py +35 -0
  72. package/templates/api/src/boards/graphql/__init__.py +7 -0
  73. package/templates/api/src/boards/graphql/access_control.py +136 -0
  74. package/templates/api/src/boards/graphql/mutations/root.py +136 -0
  75. package/templates/api/src/boards/graphql/queries/root.py +116 -0
  76. package/templates/api/src/boards/graphql/resolvers/__init__.py +8 -0
  77. package/templates/api/src/boards/graphql/resolvers/auth.py +12 -0
  78. package/templates/api/src/boards/graphql/resolvers/board.py +1055 -0
  79. package/templates/api/src/boards/graphql/resolvers/generation.py +889 -0
  80. package/templates/api/src/boards/graphql/resolvers/generator.py +50 -0
  81. package/templates/api/src/boards/graphql/resolvers/user.py +25 -0
  82. package/templates/api/src/boards/graphql/schema.py +81 -0
  83. package/templates/api/src/boards/graphql/types/board.py +102 -0
  84. package/templates/api/src/boards/graphql/types/generation.py +130 -0
  85. package/templates/api/src/boards/graphql/types/generator.py +17 -0
  86. package/templates/api/src/boards/graphql/types/user.py +47 -0
  87. package/templates/api/src/boards/jobs/repository.py +104 -0
  88. package/templates/api/src/boards/logging.py +195 -0
  89. package/templates/api/src/boards/middleware.py +339 -0
  90. package/templates/api/src/boards/progress/__init__.py +4 -0
  91. package/templates/api/src/boards/progress/models.py +25 -0
  92. package/templates/api/src/boards/progress/publisher.py +64 -0
  93. package/templates/api/src/boards/py.typed +0 -0
  94. package/templates/api/src/boards/redis_pool.py +118 -0
  95. package/templates/api/src/boards/storage/__init__.py +52 -0
  96. package/templates/api/src/boards/storage/base.py +363 -0
  97. package/templates/api/src/boards/storage/config.py +187 -0
  98. package/templates/api/src/boards/storage/factory.py +278 -0
  99. package/templates/api/src/boards/storage/implementations/__init__.py +27 -0
  100. package/templates/api/src/boards/storage/implementations/gcs.py +340 -0
  101. package/templates/api/src/boards/storage/implementations/local.py +201 -0
  102. package/templates/api/src/boards/storage/implementations/s3.py +294 -0
  103. package/templates/api/src/boards/storage/implementations/supabase.py +218 -0
  104. package/templates/api/src/boards/tenant_isolation.py +446 -0
  105. package/templates/api/src/boards/validation.py +262 -0
  106. package/templates/api/src/boards/workers/__init__.py +1 -0
  107. package/templates/api/src/boards/workers/actors.py +201 -0
  108. package/templates/api/src/boards/workers/cli.py +125 -0
  109. package/templates/api/src/boards/workers/context.py +188 -0
  110. package/templates/api/src/boards/workers/middleware.py +58 -0
  111. package/templates/api/src/py.typed +0 -0
  112. package/templates/compose.dev.yaml +39 -0
  113. package/templates/compose.yaml +109 -0
  114. package/templates/docker/env.example +23 -0
  115. package/templates/web/.env.example +28 -0
  116. package/templates/web/Dockerfile +51 -0
  117. package/templates/web/components.json +22 -0
  118. package/templates/web/imageLoader.js +18 -0
  119. package/templates/web/next-env.d.ts +5 -0
  120. package/templates/web/next.config.js +36 -0
  121. package/templates/web/package.json +37 -0
  122. package/templates/web/postcss.config.mjs +7 -0
  123. package/templates/web/public/favicon.ico +0 -0
  124. package/templates/web/src/app/boards/[boardId]/page.tsx +232 -0
  125. package/templates/web/src/app/globals.css +120 -0
  126. package/templates/web/src/app/layout.tsx +21 -0
  127. package/templates/web/src/app/page.tsx +35 -0
  128. package/templates/web/src/app/providers.tsx +18 -0
  129. package/templates/web/src/components/boards/ArtifactInputSlots.tsx +142 -0
  130. package/templates/web/src/components/boards/ArtifactPreview.tsx +125 -0
  131. package/templates/web/src/components/boards/GenerationGrid.tsx +45 -0
  132. package/templates/web/src/components/boards/GenerationInput.tsx +251 -0
  133. package/templates/web/src/components/boards/GeneratorSelector.tsx +89 -0
  134. package/templates/web/src/components/header.tsx +30 -0
  135. package/templates/web/src/components/ui/button.tsx +58 -0
  136. package/templates/web/src/components/ui/card.tsx +92 -0
  137. package/templates/web/src/components/ui/navigation-menu.tsx +168 -0
  138. package/templates/web/src/lib/utils.ts +6 -0
  139. package/templates/web/tsconfig.json +47 -0
@@ -0,0 +1,221 @@
1
+ """Authentication middleware for FastAPI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from uuid import UUID
6
+
7
+ from fastapi import Header, HTTPException
8
+
9
+ from ..database.connection import get_async_session
10
+ from ..database.seed_data import ensure_tenant
11
+ from ..logging import get_logger
12
+ from .adapters.base import AuthenticationError
13
+ from .context import DEFAULT_TENANT_UUID, AuthContext
14
+ from .factory import get_auth_adapter_cached
15
+ from .provisioning import ensure_local_user
16
+ from .tenant_extraction import extract_tenant_from_claims
17
+
18
+ logger = get_logger(__name__)
19
+
20
+
21
+ async def get_auth_context(
22
+ authorization: str | None = Header(None),
23
+ x_tenant: str | None = Header(None, alias="X-Tenant"),
24
+ ) -> AuthContext:
25
+ """
26
+ Extract authentication context from request headers.
27
+
28
+ This function:
29
+ 1. Extracts Bearer token from Authorization header
30
+ 2. Verifies token using the configured auth adapter
31
+ 3. Resolves tenant (defaults to 'default' for single-tenant)
32
+ 4. Performs JIT user provisioning
33
+ 5. Returns AuthContext for the request
34
+
35
+ For no-auth mode, any token (or "dev-token") will work.
36
+
37
+ Args:
38
+ authorization: Authorization header (Bearer token)
39
+ x_tenant: Tenant identifier header
40
+
41
+ Returns:
42
+ AuthContext with user, tenant, and token info
43
+ """
44
+ adapter = get_auth_adapter_cached()
45
+
46
+ # Check if we're in no-auth mode
47
+ is_no_auth_mode = hasattr(adapter, "default_user_id") # NoAuthAdapter has this attribute
48
+
49
+ # Handle unauthenticated requests
50
+ if not authorization:
51
+ if is_no_auth_mode:
52
+ # In no-auth mode, create a default token
53
+ authorization = "Bearer dev-token"
54
+ else:
55
+ # Use header tenant or default for unauthenticated requests
56
+ tenant_slug = x_tenant or "default"
57
+ tenant_uuid = await _resolve_tenant_uuid(tenant_slug)
58
+ return AuthContext(
59
+ user_id=None,
60
+ tenant_id=tenant_uuid,
61
+ principal=None,
62
+ token=None,
63
+ )
64
+
65
+ # Extract Bearer token
66
+ if not authorization.startswith("Bearer "):
67
+ logger.warning("Invalid authorization format received")
68
+ raise HTTPException(
69
+ status_code=401,
70
+ detail="Invalid authorization format. Expected: Bearer <token>",
71
+ headers={"WWW-Authenticate": "Bearer"},
72
+ )
73
+
74
+ token = authorization[7:] # Remove "Bearer " prefix
75
+
76
+ if not token:
77
+ logger.warning("Empty token provided")
78
+ raise HTTPException(
79
+ status_code=401,
80
+ detail="Empty token",
81
+ headers={"WWW-Authenticate": "Bearer"},
82
+ )
83
+
84
+ try:
85
+ # Verify token with auth adapter
86
+ principal = await adapter.verify_token(token)
87
+
88
+ # Extract tenant from JWT/OIDC claims with fallback to header
89
+ tenant_slug = extract_tenant_from_claims(principal, fallback_tenant=x_tenant)
90
+
91
+ logger.debug(
92
+ "Tenant resolved for authenticated request",
93
+ tenant_slug=tenant_slug,
94
+ header_tenant=x_tenant,
95
+ provider=principal.get("provider"),
96
+ subject=principal.get("subject"),
97
+ )
98
+
99
+ # Resolve tenant slug to UUID and perform JIT user provisioning
100
+ try:
101
+ async with get_async_session() as db:
102
+ # Ensure tenant exists and get its UUID
103
+ tenant_uuid = await ensure_tenant(db, slug=tenant_slug)
104
+ # Now provision the user with the tenant UUID
105
+ user_id = await ensure_local_user(db, tenant_uuid, principal)
106
+
107
+ logger.debug(
108
+ "User provisioned and tenant resolved",
109
+ user_id=str(user_id),
110
+ tenant_uuid=str(tenant_uuid),
111
+ tenant_slug=tenant_slug,
112
+ )
113
+ except Exception as db_error:
114
+ # Database connection failed, use the same deterministic fallback
115
+ logger.error(
116
+ "Database connection failed, using fallback user ID generation",
117
+ error=str(db_error),
118
+ principal_provider=principal.get("provider"),
119
+ principal_subject=principal.get("subject"),
120
+ )
121
+
122
+ import hashlib
123
+
124
+ provider = principal.get("provider", "unknown")
125
+ subject = principal.get("subject", "anonymous")
126
+ stable_input = f"{provider}:{subject}:{tenant_slug}"
127
+ user_id_hash = hashlib.sha256(stable_input.encode()).hexdigest()[:32]
128
+ # Format hash as UUID: 8-4-4-4-12 pattern
129
+ formatted_uuid = (
130
+ f"{user_id_hash[:8]}-{user_id_hash[8:12]}-"
131
+ f"{user_id_hash[12:16]}-{user_id_hash[16:20]}-"
132
+ f"{user_id_hash[20:32]}"
133
+ )
134
+ from uuid import UUID
135
+
136
+ user_id = UUID(formatted_uuid)
137
+
138
+ # Also resolve tenant_uuid in this fallback path
139
+ tenant_uuid = await _resolve_tenant_uuid(tenant_slug)
140
+
141
+ return AuthContext(
142
+ user_id=user_id,
143
+ tenant_id=tenant_uuid,
144
+ principal=principal,
145
+ token=token,
146
+ )
147
+
148
+ except AuthenticationError as e:
149
+ logger.warning("Authentication failed", error=str(e))
150
+ raise HTTPException(
151
+ status_code=401,
152
+ detail=str(e),
153
+ headers={"WWW-Authenticate": "Bearer"},
154
+ ) from e
155
+ except Exception as e:
156
+ logger.error("Unexpected authentication error", error=str(e))
157
+ raise HTTPException(
158
+ status_code=401,
159
+ detail="Authentication failed",
160
+ headers={"WWW-Authenticate": "Bearer"},
161
+ ) from e
162
+
163
+
164
+ async def get_auth_context_optional(
165
+ authorization: str | None = Header(None),
166
+ x_tenant: str | None = Header(None, alias="X-Tenant"),
167
+ ) -> AuthContext:
168
+ """
169
+ Optional authentication - returns unauthenticated context if no token.
170
+
171
+ Use this for endpoints that work both authenticated and unauthenticated.
172
+ """
173
+ try:
174
+ return await get_auth_context(authorization, x_tenant)
175
+ except HTTPException:
176
+ # Return unauthenticated context
177
+ tenant_slug = x_tenant or "default"
178
+ tenant_uuid = await _resolve_tenant_uuid(tenant_slug)
179
+ return AuthContext(
180
+ user_id=None,
181
+ tenant_id=tenant_uuid,
182
+ principal=None,
183
+ token=None,
184
+ )
185
+
186
+
187
+ async def _resolve_tenant_uuid(tenant_slug: str) -> UUID:
188
+ """
189
+ Resolve a tenant slug to its UUID.
190
+
191
+ Falls back to DEFAULT_TENANT_UUID if:
192
+ - Database lookup fails
193
+ - Tenant doesn't exist
194
+ - Running in single-tenant mode
195
+
196
+ Args:
197
+ tenant_slug: The tenant slug to resolve (e.g., "default", "acme-corp")
198
+
199
+ Returns:
200
+ UUID of the tenant, or DEFAULT_TENANT_UUID if resolution fails
201
+ """
202
+ try:
203
+ from ..database.connection import get_async_session
204
+ from ..database.seed_data import ensure_tenant
205
+
206
+ async with get_async_session() as db:
207
+ tenant_uuid = await ensure_tenant(db, slug=tenant_slug)
208
+ logger.debug(
209
+ "Resolved tenant slug to UUID",
210
+ tenant_slug=tenant_slug,
211
+ tenant_uuid=str(tenant_uuid),
212
+ )
213
+ return tenant_uuid
214
+ except Exception as e:
215
+ logger.warning(
216
+ "Failed to resolve tenant UUID, using default",
217
+ tenant_slug=tenant_slug,
218
+ error=str(e),
219
+ default_uuid=str(DEFAULT_TENANT_UUID),
220
+ )
221
+ return DEFAULT_TENANT_UUID
@@ -0,0 +1,129 @@
1
+ """User provisioning and management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from uuid import UUID
6
+
7
+ from sqlalchemy import and_, select
8
+ from sqlalchemy.ext.asyncio import AsyncSession
9
+
10
+ from ..dbmodels import Users
11
+ from ..logging import get_logger
12
+ from .adapters.base import Principal
13
+
14
+ logger = get_logger(__name__)
15
+
16
+
17
+ async def ensure_local_user(db: AsyncSession, tenant_id: UUID, principal: Principal) -> UUID:
18
+ """
19
+ Ensure a local user exists for the given principal (JIT provisioning).
20
+
21
+ This function creates a local user record if one doesn't exist for the
22
+ given (tenant_id, auth_provider, auth_subject) combination.
23
+
24
+ Args:
25
+ db: Database session
26
+ tenant_id: Tenant UUID
27
+ principal: Authenticated principal from auth provider
28
+
29
+ Returns:
30
+ UUID of the local user
31
+ """
32
+ provider = principal["provider"]
33
+ subject = principal["subject"]
34
+
35
+ # Ensure tenant_id is a UUID
36
+ if not isinstance(tenant_id, UUID):
37
+ raise ValueError(f"tenant_id must be a UUID, got {type(tenant_id)}")
38
+
39
+ # Try to find existing user
40
+ stmt = select(Users).where(
41
+ and_(
42
+ Users.tenant_id == tenant_id,
43
+ Users.auth_provider == provider,
44
+ Users.auth_subject == subject,
45
+ )
46
+ )
47
+
48
+ result = await db.execute(stmt)
49
+ user = result.scalar_one_or_none()
50
+
51
+ if user:
52
+ # Update user info if provided in principal, but preserve existing non-empty values
53
+ updated = False
54
+
55
+ email = principal.get("email")
56
+ if email and not user.email: # Only update if current email is empty/None
57
+ user.email = email
58
+ updated = True
59
+
60
+ display_name = principal.get("display_name")
61
+ # Only update if current display_name is empty/None
62
+ if display_name and not user.display_name:
63
+ user.display_name = display_name
64
+ updated = True
65
+
66
+ avatar_url = principal.get("avatar_url")
67
+ if avatar_url and not user.avatar_url: # Only update if current avatar_url is empty/None
68
+ user.avatar_url = avatar_url
69
+ updated = True
70
+
71
+ if updated:
72
+ await db.commit()
73
+ await db.refresh(user)
74
+ logger.info("Updated user info (preserving existing values)", user_id=str(user.id))
75
+
76
+ return user.id
77
+
78
+ # Create new user
79
+ user = Users(
80
+ tenant_id=tenant_id,
81
+ auth_provider=provider,
82
+ auth_subject=subject,
83
+ email=principal.get("email"),
84
+ display_name=principal.get("display_name"),
85
+ avatar_url=principal.get("avatar_url"),
86
+ metadata_={
87
+ "created_via": "jit_provisioning",
88
+ "provider_claims": principal.get("claims", {}),
89
+ },
90
+ )
91
+
92
+ db.add(user)
93
+ await db.commit()
94
+ await db.refresh(user)
95
+
96
+ logger.info(
97
+ "Created new user via JIT provisioning",
98
+ user_id=str(user.id),
99
+ tenant_id=tenant_id,
100
+ provider=provider,
101
+ )
102
+
103
+ return user.id
104
+
105
+
106
+ async def get_user_by_id(db: AsyncSession, user_id: UUID) -> Users | None:
107
+ """Get a user by ID."""
108
+ stmt = select(Users).where(Users.id == user_id)
109
+ result = await db.execute(stmt)
110
+ return result.scalar_one_or_none()
111
+
112
+
113
+ async def get_user_by_auth_info(
114
+ db: AsyncSession, tenant_id: UUID, auth_provider: str, auth_subject: str
115
+ ) -> Users | None:
116
+ """Get a user by auth provider information."""
117
+ # Ensure tenant_id is a UUID
118
+ if not isinstance(tenant_id, UUID):
119
+ raise ValueError(f"tenant_id must be a UUID, got {type(tenant_id)}")
120
+
121
+ stmt = select(Users).where(
122
+ and_(
123
+ Users.tenant_id == tenant_id,
124
+ Users.auth_provider == auth_provider,
125
+ Users.auth_subject == auth_subject,
126
+ )
127
+ )
128
+ result = await db.execute(stmt)
129
+ return result.scalar_one_or_none()
@@ -0,0 +1,278 @@
1
+ """
2
+ Tenant extraction utilities for multi-tenant authentication.
3
+
4
+ This module provides utilities to extract tenant information from JWT/OIDC claims
5
+ and other authentication contexts.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from ..config import settings
13
+ from ..logging import get_logger
14
+ from .context import Principal
15
+
16
+ logger = get_logger(__name__)
17
+
18
+
19
+ def extract_tenant_from_claims(
20
+ principal: Principal,
21
+ fallback_tenant: str | None = None,
22
+ ) -> str:
23
+ """
24
+ Extract tenant slug from JWT/OIDC claims.
25
+
26
+ This function supports multiple tenant extraction strategies:
27
+ 1. Direct 'tenant' claim in JWT
28
+ 2. Organization-based claims (org, organization, org_slug)
29
+ 3. Custom claims (configurable via settings)
30
+ 4. Domain-based extraction from email
31
+ 5. Fallback to header or default
32
+
33
+ Args:
34
+ principal: Principal with claims from JWT/OIDC
35
+ fallback_tenant: Fallback tenant slug if no tenant found in claims
36
+
37
+ Returns:
38
+ Tenant slug extracted from claims or fallback
39
+ """
40
+ claims = principal.get("claims", {})
41
+
42
+ if not claims:
43
+ logger.debug("No claims available in principal, using fallback")
44
+ return fallback_tenant or settings.default_tenant_slug
45
+
46
+ # Strategy 1: Direct tenant claim
47
+ if tenant_slug := claims.get("tenant"):
48
+ logger.info("Extracted tenant from 'tenant' claim", tenant_slug=tenant_slug)
49
+ return _validate_tenant_slug(tenant_slug)
50
+
51
+ # Strategy 2: Organization-based claims
52
+ org_claims = ["org", "organization", "org_slug", "org_name"]
53
+ for claim_name in org_claims:
54
+ if org_value := claims.get(claim_name):
55
+ tenant_slug = _normalize_tenant_slug(org_value)
56
+ logger.info(
57
+ "Extracted tenant from organization claim",
58
+ claim_name=claim_name,
59
+ org_value=org_value,
60
+ tenant_slug=tenant_slug,
61
+ )
62
+ return tenant_slug
63
+
64
+ # Strategy 3: Custom claims (configurable)
65
+ custom_tenant_claim = getattr(settings, "jwt_tenant_claim", None)
66
+ if custom_tenant_claim and (custom_value := claims.get(custom_tenant_claim)):
67
+ tenant_slug = _normalize_tenant_slug(custom_value)
68
+ logger.info(
69
+ "Extracted tenant from custom claim",
70
+ claim_name=custom_tenant_claim,
71
+ custom_value=custom_value,
72
+ tenant_slug=tenant_slug,
73
+ )
74
+ return tenant_slug
75
+
76
+ # Strategy 4: Domain-based extraction from email
77
+ if settings.multi_tenant_mode and (email := claims.get("email")):
78
+ tenant_slug = _extract_tenant_from_email_domain(email)
79
+ if tenant_slug:
80
+ logger.info(
81
+ "Extracted tenant from email domain",
82
+ email=email,
83
+ tenant_slug=tenant_slug,
84
+ )
85
+ return tenant_slug
86
+
87
+ # Strategy 5: Namespace/sub-organization claims
88
+ namespace_claims = ["namespace", "group", "team", "workspace"]
89
+ for claim_name in namespace_claims:
90
+ if namespace_value := claims.get(claim_name):
91
+ tenant_slug = _normalize_tenant_slug(namespace_value)
92
+ logger.info(
93
+ "Extracted tenant from namespace claim",
94
+ claim_name=claim_name,
95
+ namespace_value=namespace_value,
96
+ tenant_slug=tenant_slug,
97
+ )
98
+ return tenant_slug
99
+
100
+ # No tenant found in claims, use fallback
101
+ logger.debug(
102
+ "No tenant information found in JWT/OIDC claims",
103
+ available_claims=list(claims.keys()),
104
+ using_fallback=fallback_tenant or settings.default_tenant_slug,
105
+ )
106
+ return fallback_tenant or settings.default_tenant_slug
107
+
108
+
109
+ def extract_tenant_from_oidc_userinfo(
110
+ userinfo: dict[str, Any],
111
+ fallback_tenant: str | None = None,
112
+ ) -> str:
113
+ """
114
+ Extract tenant slug from OIDC userinfo endpoint response.
115
+
116
+ Args:
117
+ userinfo: Response from OIDC userinfo endpoint
118
+ fallback_tenant: Fallback tenant slug if no tenant found
119
+
120
+ Returns:
121
+ Tenant slug extracted from userinfo or fallback
122
+ """
123
+ if not userinfo:
124
+ return fallback_tenant or settings.default_tenant_slug
125
+
126
+ # Similar extraction strategies as claims
127
+ if tenant_slug := userinfo.get("tenant"):
128
+ return _validate_tenant_slug(tenant_slug)
129
+
130
+ # Organization-based extraction
131
+ for org_field in ["organization", "org", "company"]:
132
+ if org_value := userinfo.get(org_field):
133
+ return _normalize_tenant_slug(org_value)
134
+
135
+ # Groups/roles extraction
136
+ if groups := userinfo.get("groups", []):
137
+ if isinstance(groups, list) and groups:
138
+ # Use first group as tenant
139
+ return _normalize_tenant_slug(groups[0])
140
+
141
+ return fallback_tenant or settings.default_tenant_slug
142
+
143
+
144
+ def _normalize_tenant_slug(value: Any) -> str:
145
+ """
146
+ Normalize a value to a valid tenant slug.
147
+
148
+ Args:
149
+ value: Value to normalize (string, dict, etc.)
150
+
151
+ Returns:
152
+ Normalized tenant slug
153
+ """
154
+ if isinstance(value, dict):
155
+ # Extract slug if it's an object with slug/id fields
156
+ return _normalize_tenant_slug(value.get("slug") or value.get("id") or value.get("name", ""))
157
+
158
+ # Convert to string and normalize
159
+ slug = str(value).lower().strip()
160
+
161
+ # Replace spaces and invalid characters with hyphens
162
+ import re
163
+
164
+ slug = re.sub(r"[^a-z0-9-]", "-", slug)
165
+
166
+ # Remove multiple consecutive hyphens
167
+ slug = re.sub(r"-+", "-", slug)
168
+
169
+ # Remove leading/trailing hyphens
170
+ slug = slug.strip("-")
171
+
172
+ # Ensure it's not empty and not too long
173
+ if not slug:
174
+ slug = "unknown"
175
+ elif len(slug) > 50: # Reasonable limit for tenant slugs
176
+ slug = slug[:50].rstrip("-")
177
+
178
+ return slug
179
+
180
+
181
+ def _validate_tenant_slug(slug: str) -> str:
182
+ """
183
+ Validate a tenant slug format.
184
+
185
+ Args:
186
+ slug: Tenant slug to validate
187
+
188
+ Returns:
189
+ Validated slug
190
+
191
+ Raises:
192
+ ValueError: If slug is invalid
193
+ """
194
+ if not slug:
195
+ raise ValueError("Tenant slug cannot be empty")
196
+
197
+ if len(slug) > 255:
198
+ raise ValueError("Tenant slug too long (max 255 characters)")
199
+
200
+ import re
201
+
202
+ if not re.match(r"^[a-z0-9-]+$", slug):
203
+ raise ValueError("Tenant slug must contain only lowercase letters, numbers, and hyphens")
204
+
205
+ if slug.startswith("-") or slug.endswith("-"):
206
+ raise ValueError("Tenant slug cannot start or end with hyphen")
207
+
208
+ return slug
209
+
210
+
211
+ def _extract_tenant_from_email_domain(email: str) -> str | None:
212
+ """
213
+ Extract tenant slug from email domain.
214
+
215
+ This is useful for organizations that want to automatically
216
+ assign tenants based on email domains.
217
+
218
+ Args:
219
+ email: Email address
220
+
221
+ Returns:
222
+ Tenant slug derived from domain, or None if not applicable
223
+ """
224
+ try:
225
+ domain = email.split("@")[1].lower()
226
+
227
+ # Skip common public email domains
228
+ public_domains = {
229
+ "gmail.com",
230
+ "yahoo.com",
231
+ "outlook.com",
232
+ "hotmail.com",
233
+ "icloud.com",
234
+ "protonmail.com",
235
+ "aol.com",
236
+ }
237
+
238
+ if domain in public_domains:
239
+ logger.debug(
240
+ "Skipping tenant extraction from public email domain",
241
+ domain=domain,
242
+ )
243
+ return None
244
+
245
+ # Extract organization name from domain
246
+ # e.g., "user@acme-corp.com" -> "acme-corp"
247
+ org_name = domain.split(".")[0]
248
+ return _normalize_tenant_slug(org_name)
249
+
250
+ except (IndexError, AttributeError):
251
+ logger.warning("Invalid email format for domain extraction", email=email)
252
+ return None
253
+
254
+
255
+ def get_tenant_extraction_config() -> dict[str, Any]:
256
+ """
257
+ Get current tenant extraction configuration.
258
+
259
+ Returns:
260
+ Dictionary with tenant extraction settings
261
+ """
262
+ return {
263
+ "multi_tenant_mode": settings.multi_tenant_mode,
264
+ "default_tenant_slug": settings.default_tenant_slug,
265
+ "custom_tenant_claim": getattr(settings, "jwt_tenant_claim", None),
266
+ "domain_based_extraction": settings.multi_tenant_mode,
267
+ "supported_claim_names": [
268
+ "tenant",
269
+ "org",
270
+ "organization",
271
+ "org_slug",
272
+ "org_name",
273
+ "namespace",
274
+ "group",
275
+ "team",
276
+ "workspace",
277
+ ],
278
+ }