@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,446 @@
1
+ """
2
+ Tenant isolation validation and enforcement utilities.
3
+
4
+ This module provides utilities to validate and enforce tenant isolation
5
+ across the application to ensure data security in multi-tenant deployments.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import UTC
11
+ from typing import Any
12
+ from uuid import UUID
13
+
14
+ from sqlalchemy import select, text
15
+ from sqlalchemy.ext.asyncio import AsyncSession
16
+
17
+ from .config import settings
18
+ from .dbmodels import Boards, Generations, Users
19
+ from .logging import get_logger
20
+
21
+ logger = get_logger(__name__)
22
+
23
+
24
+ class TenantIsolationError(Exception):
25
+ """Raised when tenant isolation validation fails."""
26
+
27
+ pass
28
+
29
+
30
+ class TenantIsolationValidator:
31
+ """
32
+ Utility class for validating tenant isolation in multi-tenant environments.
33
+
34
+ This class provides methods to:
35
+ 1. Validate tenant-scoped queries
36
+ 2. Check for cross-tenant data access
37
+ 3. Ensure proper tenant filtering
38
+ 4. Audit tenant isolation compliance
39
+ """
40
+
41
+ def __init__(self, db: AsyncSession):
42
+ self.db = db
43
+
44
+ async def validate_user_tenant_isolation(self, user_id: UUID, tenant_id: UUID) -> bool:
45
+ """
46
+ Validate that a user belongs to the specified tenant.
47
+
48
+ Args:
49
+ user_id: UUID of the user
50
+ tenant_id: UUID of the tenant
51
+
52
+ Returns:
53
+ True if user belongs to tenant, False otherwise
54
+
55
+ Raises:
56
+ TenantIsolationError: If validation fails
57
+ """
58
+ try:
59
+ stmt = select(Users).where((Users.id == user_id) & (Users.tenant_id == tenant_id))
60
+ result = await self.db.execute(stmt)
61
+ user = result.scalar_one_or_none()
62
+
63
+ if not user:
64
+ logger.warning(
65
+ "User tenant isolation violation",
66
+ user_id=str(user_id),
67
+ expected_tenant=str(tenant_id),
68
+ )
69
+ return False
70
+
71
+ return True
72
+
73
+ except Exception as e:
74
+ logger.error(
75
+ "User tenant isolation validation failed",
76
+ user_id=str(user_id),
77
+ tenant_id=str(tenant_id),
78
+ error=str(e),
79
+ )
80
+ raise TenantIsolationError(f"User tenant validation failed: {e}") from e
81
+
82
+ async def validate_board_tenant_isolation(self, board_id: UUID, tenant_id: UUID) -> bool:
83
+ """
84
+ Validate that a board belongs to the specified tenant.
85
+
86
+ Args:
87
+ board_id: UUID of the board
88
+ tenant_id: UUID of the tenant
89
+
90
+ Returns:
91
+ True if board belongs to tenant, False otherwise
92
+ """
93
+ try:
94
+ stmt = select(Boards).where((Boards.id == board_id) & (Boards.tenant_id == tenant_id))
95
+ result = await self.db.execute(stmt)
96
+ board = result.scalar_one_or_none()
97
+
98
+ if not board:
99
+ logger.warning(
100
+ "Board tenant isolation violation",
101
+ board_id=str(board_id),
102
+ expected_tenant=str(tenant_id),
103
+ )
104
+ return False
105
+
106
+ return True
107
+
108
+ except Exception as e:
109
+ logger.error(
110
+ "Board tenant isolation validation failed",
111
+ board_id=str(board_id),
112
+ tenant_id=str(tenant_id),
113
+ error=str(e),
114
+ )
115
+ raise TenantIsolationError(f"Board tenant validation failed: {e}") from e
116
+
117
+ async def validate_generation_tenant_isolation(
118
+ self, generation_id: UUID, tenant_id: UUID
119
+ ) -> bool:
120
+ """
121
+ Validate that a generation belongs to the specified tenant.
122
+ """
123
+ try:
124
+ stmt = select(Generations).where(
125
+ (Generations.id == generation_id) & (Generations.tenant_id == tenant_id)
126
+ )
127
+ result = await self.db.execute(stmt)
128
+ generation = result.scalar_one_or_none()
129
+
130
+ if not generation:
131
+ logger.warning(
132
+ "Generation tenant isolation violation",
133
+ generation_id=str(generation_id),
134
+ expected_tenant=str(tenant_id),
135
+ )
136
+ return False
137
+
138
+ return True
139
+
140
+ except Exception as e:
141
+ logger.error(
142
+ "Generation tenant isolation validation failed",
143
+ generation_id=str(generation_id),
144
+ tenant_id=str(tenant_id),
145
+ error=str(e),
146
+ )
147
+ raise TenantIsolationError(f"Generation tenant validation failed: {e}") from e
148
+
149
+ async def audit_tenant_isolation(self, tenant_id: UUID) -> dict[str, Any]:
150
+ """
151
+ Perform comprehensive tenant isolation audit.
152
+
153
+ Args:
154
+ tenant_id: UUID of the tenant to audit
155
+
156
+ Returns:
157
+ Dictionary with audit results and statistics
158
+ """
159
+ logger.info("Starting tenant isolation audit", tenant_id=str(tenant_id))
160
+
161
+ audit_results = {
162
+ "tenant_id": str(tenant_id),
163
+ "audit_timestamp": None,
164
+ "isolation_violations": [],
165
+ "statistics": {},
166
+ "recommendations": [],
167
+ }
168
+
169
+ try:
170
+ from datetime import datetime
171
+
172
+ audit_results["audit_timestamp"] = datetime.now(UTC).isoformat()
173
+
174
+ # 1. Check for orphaned records
175
+ orphaned_records = await self._check_orphaned_records(tenant_id)
176
+ if orphaned_records:
177
+ audit_results["isolation_violations"].extend(orphaned_records)
178
+
179
+ # 2. Check cross-tenant board memberships
180
+ cross_tenant_memberships = await self._check_cross_tenant_memberships(tenant_id)
181
+ if cross_tenant_memberships:
182
+ audit_results["isolation_violations"].extend(cross_tenant_memberships)
183
+
184
+ # 3. Gather tenant statistics
185
+ audit_results["statistics"] = await self._gather_tenant_statistics(tenant_id)
186
+
187
+ # 4. Generate recommendations
188
+ audit_results["recommendations"] = self._generate_isolation_recommendations(
189
+ audit_results["isolation_violations"]
190
+ )
191
+
192
+ logger.info(
193
+ "Tenant isolation audit completed",
194
+ tenant_id=str(tenant_id),
195
+ violations_count=len(audit_results["isolation_violations"]),
196
+ )
197
+
198
+ return audit_results
199
+
200
+ except Exception as e:
201
+ logger.error(
202
+ "Tenant isolation audit failed",
203
+ tenant_id=str(tenant_id),
204
+ error=str(e),
205
+ )
206
+ raise TenantIsolationError(f"Tenant isolation audit failed: {e}") from e
207
+
208
+ async def _check_orphaned_records(self, tenant_id: UUID) -> list[dict[str, Any]]:
209
+ """Check for records that should belong to tenant but don't."""
210
+ violations = []
211
+
212
+ try:
213
+ # Check for users with boards in different tenants
214
+ stmt = text(
215
+ """
216
+ SELECT u.id as user_id, b.id as board_id, b.tenant_id as board_tenant_id
217
+ FROM users u
218
+ JOIN boards b ON u.id = b.owner_id
219
+ WHERE u.tenant_id = :tenant_id AND b.tenant_id != :tenant_id
220
+ """
221
+ )
222
+ result = await self.db.execute(stmt, {"tenant_id": tenant_id})
223
+ orphaned_boards = result.fetchall()
224
+
225
+ for row in orphaned_boards:
226
+ violations.append(
227
+ {
228
+ "type": "orphaned_board",
229
+ "description": f"User {row.user_id} owns board {row.board_id} in different tenant", # noqa: E501
230
+ "user_id": str(row.user_id),
231
+ "board_id": str(row.board_id),
232
+ "board_tenant_id": str(row.board_tenant_id),
233
+ }
234
+ )
235
+
236
+ # Check for generations with boards in different tenants
237
+ stmt = text(
238
+ """
239
+ SELECT g.id as generation_id,
240
+ g.tenant_id,
241
+ g.board_id,
242
+ b.tenant_id as board_tenant_id
243
+ FROM generations g
244
+ JOIN boards b ON g.board_id = b.id
245
+ WHERE g.tenant_id = :tenant_id AND b.tenant_id != :tenant_id
246
+ """
247
+ )
248
+ result = await self.db.execute(stmt, {"tenant_id": tenant_id})
249
+ orphaned_generations = result.fetchall()
250
+
251
+ for row in orphaned_generations:
252
+ violations.append(
253
+ {
254
+ "type": "orphaned_generation",
255
+ "description": f"Generation {row.generation_id} belongs to different tenant than its board", # noqa: E501
256
+ "generation_id": str(row.generation_id),
257
+ "board_id": str(row.board_id),
258
+ "board_tenant_id": str(row.board_tenant_id),
259
+ }
260
+ )
261
+
262
+ except Exception as e:
263
+ logger.error("Failed to check orphaned records", error=str(e))
264
+
265
+ return violations
266
+
267
+ async def _check_cross_tenant_memberships(self, tenant_id: UUID) -> list[dict[str, Any]]:
268
+ """Check for cross-tenant board memberships."""
269
+ violations = []
270
+
271
+ try:
272
+ stmt = text(
273
+ """
274
+ SELECT bm.board_id,
275
+ bm.user_id,
276
+ b.tenant_id as board_tenant_id,
277
+ u.tenant_id as user_tenant_id
278
+ FROM board_members bm
279
+ JOIN boards b ON bm.board_id = b.id
280
+ JOIN users u ON bm.user_id = u.id
281
+ WHERE b.tenant_id = :tenant_id AND u.tenant_id != :tenant_id
282
+ """
283
+ )
284
+ result = await self.db.execute(stmt, {"tenant_id": tenant_id})
285
+ cross_tenant_members = result.fetchall()
286
+
287
+ for row in cross_tenant_members:
288
+ violations.append(
289
+ {
290
+ "type": "cross_tenant_membership",
291
+ "description": "User from different tenant has board membership",
292
+ "board_id": str(row.board_id),
293
+ "user_id": str(row.user_id),
294
+ "board_tenant_id": str(row.board_tenant_id),
295
+ "user_tenant_id": str(row.user_tenant_id),
296
+ }
297
+ )
298
+
299
+ except Exception as e:
300
+ logger.error("Failed to check cross-tenant memberships", error=str(e))
301
+
302
+ return violations
303
+
304
+ async def _gather_tenant_statistics(self, tenant_id: UUID) -> dict[str, int]:
305
+ """Gather statistics for the tenant."""
306
+ stats = {}
307
+
308
+ try:
309
+ # Count users
310
+ stmt = select(Users).where(Users.tenant_id == tenant_id)
311
+ result = await self.db.execute(stmt)
312
+ stats["users_count"] = len(result.scalars().all())
313
+
314
+ # Count boards
315
+ stmt = select(Boards).where(Boards.tenant_id == tenant_id)
316
+ result = await self.db.execute(stmt)
317
+ stats["boards_count"] = len(result.scalars().all())
318
+
319
+ # Count generations
320
+ stmt = select(Generations).where(Generations.tenant_id == tenant_id)
321
+ result = await self.db.execute(stmt)
322
+ stats["generations_count"] = len(result.scalars().all())
323
+
324
+ # Count board memberships
325
+ stmt = text(
326
+ """
327
+ SELECT COUNT(*) as count
328
+ FROM board_members bm
329
+ JOIN boards b ON bm.board_id = b.id
330
+ WHERE b.tenant_id = :tenant_id
331
+ """
332
+ )
333
+ result = await self.db.execute(stmt, {"tenant_id": tenant_id})
334
+ stats["board_memberships_count"] = result.scalar()
335
+
336
+ except Exception as e:
337
+ logger.error("Failed to gather tenant statistics", error=str(e))
338
+
339
+ return stats
340
+
341
+ def _generate_isolation_recommendations(self, violations: list[dict[str, Any]]) -> list[str]:
342
+ """Generate recommendations based on isolation violations."""
343
+ recommendations = []
344
+
345
+ if not violations:
346
+ recommendations.append("Tenant isolation is properly maintained - no violations found")
347
+ return recommendations
348
+
349
+ violation_types = {v["type"] for v in violations}
350
+
351
+ if "orphaned_board" in violation_types:
352
+ recommendations.append(
353
+ "Fix orphaned boards by ensuring board tenant_id matches owner's tenant_id"
354
+ )
355
+
356
+ if "orphaned_generation" in violation_types:
357
+ recommendations.append(
358
+ "Fix orphaned generations by ensuring generation tenant_id matches board tenant_id"
359
+ )
360
+
361
+ if "cross_tenant_membership" in violation_types:
362
+ recommendations.append(
363
+ "Remove cross-tenant board memberships or migrate users to appropriate tenants"
364
+ )
365
+
366
+ recommendations.append("Run isolation audit regularly to detect future violations")
367
+ recommendations.append(
368
+ "Consider adding database constraints to prevent isolation violations"
369
+ )
370
+
371
+ return recommendations
372
+
373
+
374
+ async def ensure_tenant_isolation(
375
+ db: AsyncSession,
376
+ user_id: UUID | None,
377
+ tenant_id: UUID,
378
+ resource_type: str,
379
+ resource_id: UUID | None = None,
380
+ ) -> None:
381
+ """
382
+ Ensure tenant isolation for a specific operation.
383
+
384
+ Args:
385
+ db: Database session
386
+ user_id: ID of the user performing the operation
387
+ tenant_id: ID of the tenant context
388
+ resource_type: Type of resource being accessed (user, board, generation)
389
+ resource_id: ID of the specific resource (if applicable)
390
+
391
+ Raises:
392
+ TenantIsolationError: If isolation validation fails
393
+ """
394
+ if not settings.multi_tenant_mode:
395
+ # Skip validation in single-tenant mode
396
+ return
397
+
398
+ validator = TenantIsolationValidator(db)
399
+
400
+ try:
401
+ # Validate user belongs to tenant
402
+ if user_id:
403
+ user_valid = await validator.validate_user_tenant_isolation(user_id, tenant_id)
404
+ if not user_valid:
405
+ raise TenantIsolationError(f"User {user_id} does not belong to tenant {tenant_id}")
406
+
407
+ # Validate resource belongs to tenant (if resource_id provided)
408
+ if resource_id:
409
+ if resource_type == "board":
410
+ board_valid = await validator.validate_board_tenant_isolation(
411
+ resource_id, tenant_id
412
+ )
413
+ if not board_valid:
414
+ raise TenantIsolationError(
415
+ f"Board {resource_id} does not belong to tenant {tenant_id}"
416
+ )
417
+
418
+ elif resource_type == "generation":
419
+ generation_valid = await validator.validate_generation_tenant_isolation(
420
+ resource_id, tenant_id
421
+ )
422
+ if not generation_valid:
423
+ raise TenantIsolationError(
424
+ f"Generation {resource_id} does not belong to tenant {tenant_id}"
425
+ )
426
+
427
+ logger.debug(
428
+ "Tenant isolation validated successfully",
429
+ user_id=str(user_id) if user_id else None,
430
+ tenant_id=str(tenant_id),
431
+ resource_type=resource_type,
432
+ resource_id=str(resource_id) if resource_id else None,
433
+ )
434
+
435
+ except TenantIsolationError:
436
+ # Re-raise isolation errors
437
+ raise
438
+ except Exception as e:
439
+ logger.error(
440
+ "Tenant isolation validation error",
441
+ user_id=str(user_id) if user_id else None,
442
+ tenant_id=str(tenant_id),
443
+ resource_type=resource_type,
444
+ error=str(e),
445
+ )
446
+ raise TenantIsolationError(f"Tenant isolation validation failed: {e}") from e
@@ -0,0 +1,262 @@
1
+ """
2
+ Configuration validation for Boards application.
3
+
4
+ This module provides validation functions to ensure the application
5
+ is properly configured before startup.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from .config import settings
13
+ from .database.connection import get_async_session, test_database_connection
14
+ from .database.seed_data import ensure_default_tenant
15
+ from .logging import get_logger
16
+
17
+ logger = get_logger(__name__)
18
+
19
+
20
+ class ValidationError(Exception):
21
+ """Raised when application validation fails."""
22
+
23
+ pass
24
+
25
+
26
+ async def validate_database_connection() -> dict[str, Any]:
27
+ """
28
+ Validate that the database is accessible and responsive.
29
+
30
+ This runs before other validation checks to catch connection issues early
31
+ with clear error messages.
32
+
33
+ Returns a dictionary with validation results and connection details.
34
+ """
35
+ results = {
36
+ "valid": True,
37
+ "warnings": [],
38
+ "errors": [],
39
+ "connection_info": None,
40
+ }
41
+
42
+ success, error_message = await test_database_connection()
43
+
44
+ if success:
45
+ results["connection_info"] = {
46
+ "status": "connected",
47
+ "message": "Database connection successful",
48
+ }
49
+ logger.info("Database connection validation successful")
50
+ else:
51
+ results["valid"] = False
52
+ results["errors"].append(error_message)
53
+ logger.error("Database connection validation failed", error=error_message)
54
+
55
+ return results
56
+
57
+
58
+ async def validate_tenant_configuration() -> dict[str, Any]:
59
+ """
60
+ Validate tenant configuration and setup.
61
+
62
+ Returns a dictionary with validation results and recommendations.
63
+ Raises ValidationError if critical validation fails.
64
+ """
65
+ results = {
66
+ "valid": True,
67
+ "warnings": [],
68
+ "errors": [],
69
+ "tenant_info": None,
70
+ }
71
+
72
+ try:
73
+ async with get_async_session() as db:
74
+ if settings.multi_tenant_mode:
75
+ # Multi-tenant mode - just validate database connection
76
+ results["tenant_info"] = {
77
+ "mode": "multi_tenant",
78
+ "message": "Multi-tenant mode enabled - tenants managed dynamically",
79
+ }
80
+ logger.info("Tenant validation: Multi-tenant mode configured")
81
+
82
+ else:
83
+ # Single-tenant mode - ensure default tenant exists
84
+ try:
85
+ tenant_id = await ensure_default_tenant(db)
86
+ results["tenant_info"] = {
87
+ "mode": "single_tenant",
88
+ "tenant_id": str(tenant_id),
89
+ "slug": settings.default_tenant_slug,
90
+ "message": f"Default tenant exists: {tenant_id}",
91
+ }
92
+ logger.info(
93
+ "Tenant validation: Default tenant verified",
94
+ tenant_id=str(tenant_id),
95
+ slug=settings.default_tenant_slug,
96
+ )
97
+
98
+ except Exception as e:
99
+ error_msg = f"Failed to ensure default tenant exists: {str(e)}"
100
+ results["errors"].append(error_msg)
101
+ results["valid"] = False
102
+ logger.error("Tenant validation failed", error=str(e))
103
+
104
+ except Exception as e:
105
+ error_msg = f"Database connection failed during tenant validation: {str(e)}"
106
+ results["errors"].append(error_msg)
107
+ results["valid"] = False
108
+ logger.error("Database validation failed", error=str(e))
109
+
110
+ return results
111
+
112
+
113
+ async def validate_auth_configuration() -> dict[str, Any]:
114
+ """
115
+ Validate authentication configuration.
116
+
117
+ Returns validation results and security recommendations.
118
+ """
119
+ results = {
120
+ "valid": True,
121
+ "warnings": [],
122
+ "errors": [],
123
+ "auth_info": {
124
+ "provider": settings.auth_provider,
125
+ "multi_tenant_mode": settings.multi_tenant_mode,
126
+ },
127
+ }
128
+
129
+ # Validate auth provider configuration
130
+ if settings.auth_provider == "none":
131
+ if settings.environment.lower() in ("production", "prod"):
132
+ warning = "No-auth mode detected in production environment - this is a security risk!"
133
+ results["warnings"].append(warning)
134
+ logger.warning(warning)
135
+ else:
136
+ logger.info("Auth validation: No-auth mode enabled for development")
137
+
138
+ elif settings.auth_provider == "jwt":
139
+ if not settings.jwt_secret:
140
+ error = "JWT authentication enabled but JWT_SECRET not configured"
141
+ results["errors"].append(error)
142
+ results["valid"] = False
143
+ logger.error(error)
144
+ else:
145
+ logger.info("Auth validation: JWT authentication configured")
146
+
147
+ else:
148
+ logger.info(f"Auth validation: Using {settings.auth_provider} provider")
149
+
150
+ return results
151
+
152
+
153
+ async def validate_startup_configuration() -> dict[str, Any]:
154
+ """
155
+ Comprehensive startup validation.
156
+
157
+ This function should be called during application startup to ensure
158
+ all critical configuration is valid.
159
+ """
160
+ logger.info("Starting application configuration validation")
161
+
162
+ # Test database connection first - this catches common issues early
163
+ db_results = await validate_database_connection()
164
+
165
+ # Only proceed with other validations if database is accessible
166
+ if db_results["valid"]:
167
+ tenant_results = await validate_tenant_configuration()
168
+ auth_results = await validate_auth_configuration()
169
+ else:
170
+ # Skip tenant/auth validation if database is not accessible
171
+ logger.warning("Skipping tenant and auth validation due to database connection failure")
172
+ tenant_results = {
173
+ "valid": False,
174
+ "warnings": [],
175
+ "errors": ["Skipped due to database connection failure"],
176
+ "tenant_info": None,
177
+ }
178
+ auth_results = await validate_auth_configuration() # Auth can validate without DB
179
+
180
+ # Combine results
181
+ combined_results = {
182
+ "overall_valid": db_results["valid"] and tenant_results["valid"] and auth_results["valid"],
183
+ "database": db_results,
184
+ "tenant": tenant_results,
185
+ "auth": auth_results,
186
+ "environment": {
187
+ "auth_provider": settings.auth_provider,
188
+ "multi_tenant_mode": settings.multi_tenant_mode,
189
+ "environment": settings.environment,
190
+ "debug": settings.debug,
191
+ },
192
+ }
193
+
194
+ # Log summary
195
+ if combined_results["overall_valid"]:
196
+ logger.info("Application configuration validation completed successfully")
197
+ else:
198
+ all_errors = (
199
+ db_results.get("errors", [])
200
+ + tenant_results.get("errors", [])
201
+ + auth_results.get("errors", [])
202
+ )
203
+ logger.error(
204
+ "Application configuration validation failed",
205
+ errors=all_errors,
206
+ )
207
+
208
+ # Log warnings
209
+ all_warnings = (
210
+ db_results.get("warnings", [])
211
+ + tenant_results.get("warnings", [])
212
+ + auth_results.get("warnings", [])
213
+ )
214
+ if all_warnings:
215
+ logger.warning(
216
+ "Configuration warnings detected",
217
+ warnings=all_warnings,
218
+ )
219
+
220
+ return combined_results
221
+
222
+
223
+ def get_startup_recommendations(validation_results: dict[str, Any]) -> list[str]:
224
+ """
225
+ Generate startup recommendations based on validation results.
226
+ """
227
+ recommendations = []
228
+
229
+ # Database recommendations
230
+ if not validation_results.get("database", {}).get("valid", False):
231
+ recommendations.append(
232
+ "Database connection failed - check that PostgreSQL is running and accessible"
233
+ )
234
+ return recommendations # Return early if database is not accessible
235
+
236
+ # Tenant recommendations
237
+ tenant_info = validation_results["tenant"].get("tenant_info")
238
+ if tenant_info and tenant_info["mode"] == "single_tenant":
239
+ recommendations.append(f"Single-tenant mode active with tenant: {tenant_info['tenant_id']}")
240
+
241
+ # Auth recommendations
242
+ auth_info = validation_results["auth"]["auth_info"]
243
+ if auth_info["provider"] == "none" and settings.environment.lower() not in (
244
+ "development",
245
+ "dev",
246
+ ):
247
+ recommendations.append(
248
+ "Consider configuring a proper authentication provider for non-development environments"
249
+ )
250
+
251
+ # Error recommendations
252
+ if not validation_results["overall_valid"]:
253
+ recommendations.append("Fix configuration errors before deploying to production")
254
+
255
+ # Success recommendations
256
+ if validation_results["overall_valid"] and not recommendations:
257
+ if auth_info["multi_tenant_mode"]:
258
+ recommendations.append("Multi-tenant configuration is ready for operation")
259
+ else:
260
+ recommendations.append("Single-tenant configuration is ready for operation")
261
+
262
+ return recommendations