|
8 | 8 | Seeding is idempotent - existing scopes/roles are not duplicated. |
9 | 9 | """ |
10 | 10 |
|
11 | | -from typing import NamedTuple |
| 11 | +from typing import NamedTuple, TypedDict |
12 | 12 | from uuid import UUID, uuid4 |
13 | 13 |
|
14 | 14 | from sqlalchemy import select |
@@ -197,7 +197,18 @@ class RoleDefinition(NamedTuple): |
197 | 197 | ), |
198 | 198 | } |
199 | 199 |
|
200 | | -PRESET_ROLE_SLUGS: frozenset[str] = frozenset(PRESET_ROLE_DEFINITIONS) |
| 200 | +_CUSTOM_SCOPE_BATCH_ROWS = 5_000 |
| 201 | + |
| 202 | + |
| 203 | +class ScopeInsertRow(TypedDict): |
| 204 | + id: UUID |
| 205 | + name: str |
| 206 | + resource: str |
| 207 | + action: str |
| 208 | + description: str |
| 209 | + source: ScopeSource |
| 210 | + source_ref: str |
| 211 | + organization_id: UUID | None |
201 | 212 |
|
202 | 213 |
|
203 | 214 | # ============================================================================= |
@@ -256,47 +267,172 @@ async def seed_registry_scopes( |
256 | 267 | session: AsyncSession, |
257 | 268 | action_keys: list[str], |
258 | 269 | ) -> int: |
259 | | - """Seed registry action scopes in bulk. |
| 270 | + """Seed registry scopes. |
| 271 | +
|
| 272 | + Current behavior has two explicit steps: |
| 273 | + 1. Seed platform registry scopes. |
| 274 | + 2. Seed custom registry scopes |
| 275 | + """ |
| 276 | + platform_inserted = await _seed_registry_scopes( |
| 277 | + session, |
| 278 | + action_keys, |
| 279 | + source=ScopeSource.PLATFORM, |
| 280 | + organization_id=None, |
| 281 | + ) |
| 282 | + custom_inserted = await _seed_custom_registry_scopes(session, action_keys) |
| 283 | + return platform_inserted + custom_inserted |
| 284 | + |
| 285 | + |
| 286 | +async def seed_platform_registry_scopes( |
| 287 | + session: AsyncSession, |
| 288 | + action_keys: list[str], |
| 289 | +) -> int: |
| 290 | + """Seed platform registry action scopes in bulk.""" |
| 291 | + return await _seed_registry_scopes( |
| 292 | + session, |
| 293 | + action_keys, |
| 294 | + source=ScopeSource.PLATFORM, |
| 295 | + organization_id=None, |
| 296 | + ) |
260 | 297 |
|
261 | | - Creates scopes for all action keys that don't already exist. |
262 | | - Uses PostgreSQL upsert for efficiency. |
| 298 | + |
| 299 | +async def _seed_custom_registry_scopes( |
| 300 | + session: AsyncSession, |
| 301 | + action_keys: list[str], |
| 302 | +) -> int: |
| 303 | + """Seed custom registry scopes for all organizations using chunked upserts.""" |
| 304 | + if not action_keys: |
| 305 | + return 0 |
| 306 | + |
| 307 | + org_stmt = select(Organization.id) |
| 308 | + org_result = await session.execute(org_stmt) |
| 309 | + org_ids = [org_id for (org_id,) in org_result.tuples().all()] |
| 310 | + if not org_ids: |
| 311 | + return 0 |
| 312 | + |
| 313 | + logger.info( |
| 314 | + "Seeding registry scopes", |
| 315 | + num_actions=len(action_keys), |
| 316 | + source=ScopeSource.CUSTOM.value, |
| 317 | + num_organizations=len(org_ids), |
| 318 | + ) |
| 319 | + |
| 320 | + inserted_count = 0 |
| 321 | + batch_values: list[ScopeInsertRow] = [] |
| 322 | + for org_id in org_ids: |
| 323 | + for key in action_keys: |
| 324 | + batch_values.append( |
| 325 | + _build_registry_scope_row( |
| 326 | + action_key=key, |
| 327 | + source=ScopeSource.CUSTOM, |
| 328 | + organization_id=org_id, |
| 329 | + ) |
| 330 | + ) |
| 331 | + if len(batch_values) >= _CUSTOM_SCOPE_BATCH_ROWS: |
| 332 | + inserted_count += await _upsert_registry_scope_rows( |
| 333 | + session=session, |
| 334 | + values=batch_values, |
| 335 | + source=ScopeSource.CUSTOM, |
| 336 | + ) |
| 337 | + batch_values.clear() |
| 338 | + |
| 339 | + if batch_values: |
| 340 | + inserted_count += await _upsert_registry_scope_rows( |
| 341 | + session=session, |
| 342 | + values=batch_values, |
| 343 | + source=ScopeSource.CUSTOM, |
| 344 | + ) |
| 345 | + |
| 346 | + logger.info( |
| 347 | + "Registry scopes seeded", |
| 348 | + inserted=inserted_count, |
| 349 | + total=len(org_ids) * len(action_keys), |
| 350 | + source=ScopeSource.CUSTOM.value, |
| 351 | + ) |
| 352 | + return inserted_count |
| 353 | + |
| 354 | + |
| 355 | +async def _seed_registry_scopes( |
| 356 | + session: AsyncSession, |
| 357 | + action_keys: list[str], |
| 358 | + *, |
| 359 | + source: ScopeSource, |
| 360 | + organization_id: UUID | None, |
| 361 | +) -> int: |
| 362 | + """Seed registry action scopes with explicit source and ownership. |
263 | 363 |
|
264 | 364 | Args: |
265 | 365 | session: Database session |
266 | 366 | action_keys: List of action keys (e.g., ["tools.okta.list_users", "core.http_request"]) |
| 367 | + source: Scope ownership category (platform or custom). |
| 368 | + organization_id: Target organization for custom scopes, None for platform. |
267 | 369 |
|
268 | 370 | Returns: |
269 | | - Number of scopes inserted |
| 371 | + Number of scopes inserted. |
270 | 372 | """ |
271 | 373 | if not action_keys: |
272 | 374 | return 0 |
273 | 375 |
|
274 | | - logger.info("Seeding registry scopes", num_actions=len(action_keys)) |
| 376 | + logger.info( |
| 377 | + "Seeding registry scopes", |
| 378 | + num_actions=len(action_keys), |
| 379 | + ) |
275 | 380 |
|
276 | 381 | values = [ |
277 | | - { |
278 | | - "id": uuid4(), |
279 | | - "name": f"action:{key}:execute", |
280 | | - "resource": "action", |
281 | | - "action": "execute", |
282 | | - "description": f"Execute {key} action", |
283 | | - "source": ScopeSource.PLATFORM, |
284 | | - "source_ref": key, |
285 | | - "organization_id": None, |
286 | | - } |
| 382 | + _build_registry_scope_row( |
| 383 | + action_key=key, source=source, organization_id=organization_id |
| 384 | + ) |
287 | 385 | for key in action_keys |
288 | 386 | ] |
289 | 387 |
|
290 | | - stmt = pg_insert(Scope).values(values) |
291 | | - stmt = stmt.on_conflict_do_nothing( |
292 | | - index_elements=["name"], index_where=Scope.organization_id.is_(None) |
| 388 | + return await _upsert_registry_scope_rows( |
| 389 | + session=session, |
| 390 | + values=values, |
| 391 | + source=source, |
293 | 392 | ) |
294 | 393 |
|
| 394 | + |
| 395 | +def _build_registry_scope_row( |
| 396 | + *, action_key: str, source: ScopeSource, organization_id: UUID | None |
| 397 | +) -> ScopeInsertRow: |
| 398 | + """Build a single scope insert row for a registry action key.""" |
| 399 | + return { |
| 400 | + "id": uuid4(), |
| 401 | + "name": f"action:{action_key}:execute", |
| 402 | + "resource": "action", |
| 403 | + "action": "execute", |
| 404 | + "description": f"Execute {action_key} action", |
| 405 | + "source": source, |
| 406 | + "source_ref": action_key, |
| 407 | + "organization_id": organization_id, |
| 408 | + } |
| 409 | + |
| 410 | + |
| 411 | +async def _upsert_registry_scope_rows( |
| 412 | + *, |
| 413 | + session: AsyncSession, |
| 414 | + values: list[ScopeInsertRow], |
| 415 | + source: ScopeSource, |
| 416 | +) -> int: |
| 417 | + """Insert scope rows with conflict handling for platform vs org-scoped scopes.""" |
| 418 | + if not values: |
| 419 | + return 0 |
| 420 | + stmt = pg_insert(Scope).values(values) |
| 421 | + if source == ScopeSource.PLATFORM: |
| 422 | + stmt = stmt.on_conflict_do_nothing( |
| 423 | + index_elements=["name"], index_where=Scope.organization_id.is_(None) |
| 424 | + ) |
| 425 | + else: |
| 426 | + stmt = stmt.on_conflict_do_nothing(index_elements=["organization_id", "name"]) |
| 427 | + |
295 | 428 | result = await session.execute(stmt) |
296 | | - inserted_count = result.rowcount if result.rowcount else 0 # pyright: ignore[reportAttributeAccessIssue] |
| 429 | + inserted_count = result.rowcount or 0 # pyright: ignore[reportAttributeAccessIssue] |
297 | 430 |
|
298 | 431 | logger.info( |
299 | | - "Registry scopes seeded", inserted=inserted_count, total=len(action_keys) |
| 432 | + "Registry scopes seeded", |
| 433 | + inserted=inserted_count, |
| 434 | + total=len(values), |
| 435 | + source=source.value, |
300 | 436 | ) |
301 | 437 | return inserted_count |
302 | 438 |
|
|
0 commit comments