from __future__ import annotations import uuid from collections.abc import Iterable from sqlalchemy.orm import Session from govoplan_admin.backend.db.models import GovernanceTemplate, GovernanceTemplateAssignment from govoplan_core.admin.common import AdminConflictError, AdminValidationError, slugify from govoplan_core.core.access import ( CAPABILITY_ACCESS_GOVERNANCE_PROJECTION_V1, AccessGovernanceProjectionV1, GovernanceProjectionBatch, GovernanceProjectionCommand, GovernanceProjectionOutcome, GovernanceProjectionResult, GovernanceTemplateMaterialization, ) from govoplan_core.core.runtime import get_registry from govoplan_core.security.permissions import validate_tenant_permissions from govoplan_core.tenancy.scope import Tenant TEMPLATE_KINDS = {"group", "role"} ASSIGNMENT_MODES = {"available", "required"} MAX_ASSIGNMENTS_PER_TEMPLATE = 500 MAX_TEMPLATES_PER_SYNCHRONIZATION = 100 def _governance_projection() -> AccessGovernanceProjectionV1: registry = get_registry() if registry is None or not registry.has_capability(CAPABILITY_ACCESS_GOVERNANCE_PROJECTION_V1): raise AdminValidationError("Access governance projection v1 capability is not configured.") capability = registry.require_capability(CAPABILITY_ACCESS_GOVERNANCE_PROJECTION_V1) if not isinstance(capability, AccessGovernanceProjectionV1): raise AdminValidationError("Access governance projection v1 capability is invalid.") return capability def _materialization( item: GovernanceTemplate, *, tenant_id: str, required: bool = False, ) -> GovernanceTemplateMaterialization: return GovernanceTemplateMaterialization( template_id=item.id, kind=item.kind, # type: ignore[arg-type] tenant_id=tenant_id, slug=item.slug, name=item.name, description=item.description, permissions=tuple(item.permissions or []), is_active=item.is_active, required=required, ) def _command( item: GovernanceTemplate, assignment: GovernanceTemplateAssignment, *, operation: str, source: str, ) -> GovernanceProjectionCommand: return GovernanceProjectionCommand( assignment_id=assignment.id, operation=operation, # type: ignore[arg-type] template=_materialization( item, tenant_id=assignment.tenant_id, required=assignment.mode == "required", ), provenance={ "source": source, "template_id": item.id, "assignment_mode": assignment.mode, }, ) def _reconcile( session: Session, commands: Iterable[GovernanceProjectionCommand], *, source: str, dry_run: bool = False, ) -> GovernanceProjectionResult: bounded = tuple(commands) if not bounded: return GovernanceProjectionResult( operation_id=f"admin-governance:{uuid.uuid4()}", outcomes=(), dry_run=dry_run, ) batch = GovernanceProjectionBatch( operation_id=f"admin-governance:{uuid.uuid4()}", commands=bounded, dry_run=dry_run, ) result = _governance_projection().reconcile(session, batch) expected = {item.assignment_id for item in bounded} returned = {item.assignment_id for item in result.outcomes} if expected != returned or len(result.outcomes) != len(bounded): raise AdminValidationError(f"Access governance projection returned an incomplete {source} result.") return result def _raise_blocked(result: GovernanceProjectionResult) -> None: if not result.blocked: return summaries = [ f"{item.tenant_id}: {item.message or ', '.join(item.blocker_codes) or item.status}" for item in result.blocked[:10] ] suffix = "" if len(result.blocked) <= 10 else f" (+{len(result.blocked) - 10} more)" raise AdminConflictError("Governance synchronization blocked: " + "; ".join(summaries) + suffix) def validate_template(kind: str, permissions: list[str]) -> list[str]: if kind not in TEMPLATE_KINDS: raise AdminValidationError("Template kind must be group or role.") if kind == "group": if permissions: raise AdminValidationError("Group templates do not contain permissions; assign roles to groups inside each tenant.") return [] try: return validate_tenant_permissions(permissions) except ValueError as exc: raise AdminValidationError(str(exc)) from exc def create_template( session: Session, *, kind: str, slug: str, name: str, description: str | None, permissions: list[str], is_active: bool, assignments: list[dict[str, str]], ) -> GovernanceTemplate: normalized_slug = slugify(slug) permissions = validate_template(kind, permissions) exists = session.query(GovernanceTemplate).filter( GovernanceTemplate.kind == kind, GovernanceTemplate.slug == normalized_slug, ).first() if exists: raise AdminConflictError(f"A {kind} template with slug {normalized_slug!r} already exists.") item = GovernanceTemplate( kind=kind, slug=normalized_slug, name=name.strip(), description=description, permissions=permissions, is_active=is_active, ) session.add(item) session.flush() set_template_assignments(session, item, assignments) return item def update_template( session: Session, item: GovernanceTemplate, *, name: str, description: str | None, permissions: list[str], is_active: bool, assignments: list[dict[str, str]], ) -> GovernanceTemplate: item.name = name.strip() item.description = description item.permissions = validate_template(item.kind, permissions) item.is_active = is_active set_template_assignments(session, item, assignments) return item def set_template_assignments( session: Session, item: GovernanceTemplate, assignments: list[dict[str, str]], ) -> GovernanceProjectionResult: if len(assignments) > MAX_ASSIGNMENTS_PER_TEMPLATE: raise AdminValidationError( f"A governance template supports at most {MAX_ASSIGNMENTS_PER_TEMPLATE} tenant assignments." ) desired: dict[str, str] = {} for assignment in assignments: tenant_id = assignment.get("tenant_id", "") mode = assignment.get("mode", "available") if not tenant_id: raise AdminValidationError("Template assignments require a tenant id.") if tenant_id in desired: raise AdminValidationError(f"Duplicate tenant assignment: {tenant_id}") if mode not in ASSIGNMENT_MODES: raise AdminValidationError("Template assignment mode must be available or required.") desired[tenant_id] = mode known_tenants = { tenant_id for (tenant_id,) in session.query(Tenant.id).filter(Tenant.id.in_(desired)).all() } if desired else set() missing_tenants = sorted(set(desired) - known_tenants) if missing_tenants: preview = ", ".join(missing_tenants[:10]) suffix = "" if len(missing_tenants) <= 10 else f" (+{len(missing_tenants) - 10} more)" raise AdminValidationError(f"Unknown tenants: {preview}{suffix}") existing = { row.tenant_id: row for row in session.query(GovernanceTemplateAssignment) .filter(GovernanceTemplateAssignment.template_id == item.id) .all() } removed = [row for tenant_id, row in existing.items() if tenant_id not in desired] retained: list[GovernanceTemplateAssignment] = [] for tenant_id, mode in desired.items(): row = existing.get(tenant_id) if row is None: row = GovernanceTemplateAssignment(template_id=item.id, tenant_id=tenant_id, mode=mode) session.add(row) else: row.mode = mode retained.append(row) session.flush() commands = [ _command(item, row, operation="remove", source="admin.assignment-reconciliation") for row in removed ] commands.extend( _command(item, row, operation="upsert", source="admin.assignment-reconciliation") for row in retained ) result = _reconcile( session, commands, source="assignment reconciliation", ) _raise_blocked(result) for row in removed: session.delete(row) session.flush() return result def sync_template(session: Session, item: GovernanceTemplate) -> GovernanceProjectionResult: return synchronize_templates(session, template_ids=(item.id,), dry_run=False) def synchronize_templates( session: Session, *, template_ids: Iterable[str], dry_run: bool, ) -> GovernanceProjectionResult: requested = tuple(dict.fromkeys(template_ids)) if not requested or len(requested) > MAX_TEMPLATES_PER_SYNCHRONIZATION: raise AdminValidationError( f"Select between 1 and {MAX_TEMPLATES_PER_SYNCHRONIZATION} governance templates." ) templates = session.query(GovernanceTemplate).filter(GovernanceTemplate.id.in_(requested)).all() templates_by_id = {item.id: item for item in templates} missing_templates = sorted(set(requested) - set(templates_by_id)) if missing_templates: raise AdminValidationError("Unknown governance templates: " + ", ".join(missing_templates[:10])) assignments = ( session.query(GovernanceTemplateAssignment) .filter(GovernanceTemplateAssignment.template_id.in_(requested)) .order_by( GovernanceTemplateAssignment.template_id.asc(), GovernanceTemplateAssignment.tenant_id.asc(), ) .all() ) if len(assignments) > MAX_ASSIGNMENTS_PER_TEMPLATE: raise AdminValidationError( f"A synchronization run supports at most {MAX_ASSIGNMENTS_PER_TEMPLATE} tenant assignments." ) tenant_ids = {item.tenant_id for item in assignments} known_tenants = { tenant_id for (tenant_id,) in session.query(Tenant.id).filter(Tenant.id.in_(tenant_ids)).all() } if tenant_ids else set() valid_commands = [ _command( templates_by_id[assignment.template_id], assignment, operation="upsert", source="admin.bulk-synchronization", ) for assignment in assignments if assignment.tenant_id in known_tenants ] result = _reconcile( session, valid_commands, source="bulk synchronization", dry_run=dry_run, ) invalid_outcomes = [ GovernanceProjectionOutcome( assignment_id=assignment.id, template_id=assignment.template_id, tenant_id=assignment.tenant_id, kind=templates_by_id[assignment.template_id].kind, # type: ignore[arg-type] operation="upsert", status="blocked", blocker_codes=("unknown_tenant",), message="The assigned tenant no longer exists.", provenance={ "source": "admin.bulk-synchronization", "template_id": assignment.template_id, "assignment_mode": assignment.mode, }, ) for assignment in assignments if assignment.tenant_id not in known_tenants ] outcome_by_assignment = { item.assignment_id: item for item in (*result.outcomes, *invalid_outcomes) } return GovernanceProjectionResult( operation_id=result.operation_id, outcomes=tuple(outcome_by_assignment[item.id] for item in assignments), dry_run=dry_run, ) def delete_template(session: Session, item: GovernanceTemplate) -> GovernanceProjectionResult: assignments = session.query(GovernanceTemplateAssignment).filter( GovernanceTemplateAssignment.template_id == item.id ).all() result = _reconcile( session, ( _command(item, assignment, operation="remove", source="admin.template-deletion") for assignment in assignments ), source="template deletion", ) _raise_blocked(result) session.delete(item) return result