@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.
- package/README.md +191 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +887 -0
- package/dist/index.js.map +1 -0
- package/package.json +64 -0
- package/templates/README.md +120 -0
- package/templates/api/.env.example +62 -0
- package/templates/api/Dockerfile +32 -0
- package/templates/api/README.md +132 -0
- package/templates/api/alembic/env.py +106 -0
- package/templates/api/alembic/script.py.mako +28 -0
- package/templates/api/alembic/versions/20250101_000000_initial_schema.py +448 -0
- package/templates/api/alembic/versions/20251022_174729_remove_provider_name_from_generations.py +71 -0
- package/templates/api/alembic/versions/20251023_165852_switch_to_declarative_base_and_mapping.py +411 -0
- package/templates/api/alembic/versions/2025925_62735_add_seed_data_for_default_tenant.py +85 -0
- package/templates/api/alembic.ini +36 -0
- package/templates/api/config/generators.yaml +25 -0
- package/templates/api/config/storage_config.yaml +26 -0
- package/templates/api/docs/ADDING_GENERATORS.md +409 -0
- package/templates/api/docs/GENERATORS_API.md +502 -0
- package/templates/api/docs/MIGRATIONS.md +472 -0
- package/templates/api/docs/storage_providers.md +337 -0
- package/templates/api/pyproject.toml +165 -0
- package/templates/api/src/boards/__init__.py +10 -0
- package/templates/api/src/boards/api/app.py +171 -0
- package/templates/api/src/boards/api/auth.py +75 -0
- package/templates/api/src/boards/api/endpoints/__init__.py +3 -0
- package/templates/api/src/boards/api/endpoints/jobs.py +76 -0
- package/templates/api/src/boards/api/endpoints/setup.py +505 -0
- package/templates/api/src/boards/api/endpoints/sse.py +129 -0
- package/templates/api/src/boards/api/endpoints/storage.py +74 -0
- package/templates/api/src/boards/api/endpoints/tenant_registration.py +296 -0
- package/templates/api/src/boards/api/endpoints/webhooks.py +13 -0
- package/templates/api/src/boards/auth/__init__.py +15 -0
- package/templates/api/src/boards/auth/adapters/__init__.py +20 -0
- package/templates/api/src/boards/auth/adapters/auth0.py +220 -0
- package/templates/api/src/boards/auth/adapters/base.py +73 -0
- package/templates/api/src/boards/auth/adapters/clerk.py +172 -0
- package/templates/api/src/boards/auth/adapters/jwt.py +122 -0
- package/templates/api/src/boards/auth/adapters/none.py +102 -0
- package/templates/api/src/boards/auth/adapters/oidc.py +284 -0
- package/templates/api/src/boards/auth/adapters/supabase.py +110 -0
- package/templates/api/src/boards/auth/context.py +35 -0
- package/templates/api/src/boards/auth/factory.py +115 -0
- package/templates/api/src/boards/auth/middleware.py +221 -0
- package/templates/api/src/boards/auth/provisioning.py +129 -0
- package/templates/api/src/boards/auth/tenant_extraction.py +278 -0
- package/templates/api/src/boards/cli.py +354 -0
- package/templates/api/src/boards/config.py +116 -0
- package/templates/api/src/boards/database/__init__.py +7 -0
- package/templates/api/src/boards/database/cli.py +110 -0
- package/templates/api/src/boards/database/connection.py +252 -0
- package/templates/api/src/boards/database/models.py +19 -0
- package/templates/api/src/boards/database/seed_data.py +182 -0
- package/templates/api/src/boards/dbmodels/__init__.py +455 -0
- package/templates/api/src/boards/generators/__init__.py +57 -0
- package/templates/api/src/boards/generators/artifacts.py +53 -0
- package/templates/api/src/boards/generators/base.py +140 -0
- package/templates/api/src/boards/generators/implementations/__init__.py +12 -0
- package/templates/api/src/boards/generators/implementations/audio/__init__.py +3 -0
- package/templates/api/src/boards/generators/implementations/audio/whisper.py +66 -0
- package/templates/api/src/boards/generators/implementations/image/__init__.py +3 -0
- package/templates/api/src/boards/generators/implementations/image/dalle3.py +93 -0
- package/templates/api/src/boards/generators/implementations/image/flux_pro.py +85 -0
- package/templates/api/src/boards/generators/implementations/video/__init__.py +3 -0
- package/templates/api/src/boards/generators/implementations/video/lipsync.py +70 -0
- package/templates/api/src/boards/generators/loader.py +253 -0
- package/templates/api/src/boards/generators/registry.py +114 -0
- package/templates/api/src/boards/generators/resolution.py +515 -0
- package/templates/api/src/boards/generators/testmods/class_gen.py +34 -0
- package/templates/api/src/boards/generators/testmods/import_side_effect.py +35 -0
- package/templates/api/src/boards/graphql/__init__.py +7 -0
- package/templates/api/src/boards/graphql/access_control.py +136 -0
- package/templates/api/src/boards/graphql/mutations/root.py +136 -0
- package/templates/api/src/boards/graphql/queries/root.py +116 -0
- package/templates/api/src/boards/graphql/resolvers/__init__.py +8 -0
- package/templates/api/src/boards/graphql/resolvers/auth.py +12 -0
- package/templates/api/src/boards/graphql/resolvers/board.py +1055 -0
- package/templates/api/src/boards/graphql/resolvers/generation.py +889 -0
- package/templates/api/src/boards/graphql/resolvers/generator.py +50 -0
- package/templates/api/src/boards/graphql/resolvers/user.py +25 -0
- package/templates/api/src/boards/graphql/schema.py +81 -0
- package/templates/api/src/boards/graphql/types/board.py +102 -0
- package/templates/api/src/boards/graphql/types/generation.py +130 -0
- package/templates/api/src/boards/graphql/types/generator.py +17 -0
- package/templates/api/src/boards/graphql/types/user.py +47 -0
- package/templates/api/src/boards/jobs/repository.py +104 -0
- package/templates/api/src/boards/logging.py +195 -0
- package/templates/api/src/boards/middleware.py +339 -0
- package/templates/api/src/boards/progress/__init__.py +4 -0
- package/templates/api/src/boards/progress/models.py +25 -0
- package/templates/api/src/boards/progress/publisher.py +64 -0
- package/templates/api/src/boards/py.typed +0 -0
- package/templates/api/src/boards/redis_pool.py +118 -0
- package/templates/api/src/boards/storage/__init__.py +52 -0
- package/templates/api/src/boards/storage/base.py +363 -0
- package/templates/api/src/boards/storage/config.py +187 -0
- package/templates/api/src/boards/storage/factory.py +278 -0
- package/templates/api/src/boards/storage/implementations/__init__.py +27 -0
- package/templates/api/src/boards/storage/implementations/gcs.py +340 -0
- package/templates/api/src/boards/storage/implementations/local.py +201 -0
- package/templates/api/src/boards/storage/implementations/s3.py +294 -0
- package/templates/api/src/boards/storage/implementations/supabase.py +218 -0
- package/templates/api/src/boards/tenant_isolation.py +446 -0
- package/templates/api/src/boards/validation.py +262 -0
- package/templates/api/src/boards/workers/__init__.py +1 -0
- package/templates/api/src/boards/workers/actors.py +201 -0
- package/templates/api/src/boards/workers/cli.py +125 -0
- package/templates/api/src/boards/workers/context.py +188 -0
- package/templates/api/src/boards/workers/middleware.py +58 -0
- package/templates/api/src/py.typed +0 -0
- package/templates/compose.dev.yaml +39 -0
- package/templates/compose.yaml +109 -0
- package/templates/docker/env.example +23 -0
- package/templates/web/.env.example +28 -0
- package/templates/web/Dockerfile +51 -0
- package/templates/web/components.json +22 -0
- package/templates/web/imageLoader.js +18 -0
- package/templates/web/next-env.d.ts +5 -0
- package/templates/web/next.config.js +36 -0
- package/templates/web/package.json +37 -0
- package/templates/web/postcss.config.mjs +7 -0
- package/templates/web/public/favicon.ico +0 -0
- package/templates/web/src/app/boards/[boardId]/page.tsx +232 -0
- package/templates/web/src/app/globals.css +120 -0
- package/templates/web/src/app/layout.tsx +21 -0
- package/templates/web/src/app/page.tsx +35 -0
- package/templates/web/src/app/providers.tsx +18 -0
- package/templates/web/src/components/boards/ArtifactInputSlots.tsx +142 -0
- package/templates/web/src/components/boards/ArtifactPreview.tsx +125 -0
- package/templates/web/src/components/boards/GenerationGrid.tsx +45 -0
- package/templates/web/src/components/boards/GenerationInput.tsx +251 -0
- package/templates/web/src/components/boards/GeneratorSelector.tsx +89 -0
- package/templates/web/src/components/header.tsx +30 -0
- package/templates/web/src/components/ui/button.tsx +58 -0
- package/templates/web/src/components/ui/card.tsx +92 -0
- package/templates/web/src/components/ui/navigation-menu.tsx +168 -0
- package/templates/web/src/lib/utils.ts +6 -0
- 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
|