from __future__ import annotations from collections import defaultdict from sqlalchemy import func from sqlalchemy.orm import Session from govoplan_access.backend.db.models import ( Group, GroupRoleAssignment, Role, UserGroupMembership, UserRoleAssignment, new_uuid, ) from govoplan_core.admin.common import AdminConflictError from govoplan_core.core.access import ( AccessGovernanceMaterializer, AccessGovernanceProjectionV1, GovernanceProjectionBatch, GovernanceProjectionCommand, GovernanceProjectionOutcome, GovernanceProjectionResult, GovernanceTemplateMaterialization, ) from govoplan_core.core.runtime import get_registry class SqlAccessGovernanceMaterializer( AccessGovernanceMaterializer, AccessGovernanceProjectionV1, ): """Reconcile governance projections with a constant number of bulk reads.""" def reconcile( self, session: object, batch: GovernanceProjectionBatch, ) -> GovernanceProjectionResult: db = _session(session) commands = tuple(batch.commands) group_commands = tuple(item for item in commands if item.template.kind == "group") role_commands = tuple(item for item in commands if item.template.kind == "role") groups, duplicate_group_keys = _managed_groups(db, group_commands) roles, duplicate_role_keys = _managed_roles(db, role_commands) used_group_slugs = _used_slugs(db, Group, group_commands) used_role_slugs = _used_slugs(db, Role, role_commands) group_ids = {item.id for item in groups.values()} role_ids = {item.id for item in roles.values()} group_memberships = _assignment_counts(db, UserGroupMembership, UserGroupMembership.group_id, group_ids) group_role_links = _assignment_counts(db, GroupRoleAssignment, GroupRoleAssignment.group_id, group_ids) role_user_links = _assignment_counts(db, UserRoleAssignment, UserRoleAssignment.role_id, role_ids) role_group_links = _assignment_counts(db, GroupRoleAssignment, GroupRoleAssignment.role_id, role_ids) outcomes: list[GovernanceProjectionOutcome] = [] for command in commands: key = (command.template.tenant_id, command.template.template_id) if command.template.kind == "group": outcome = self._reconcile_group( db, command, groups, duplicate_group_keys, used_group_slugs, group_memberships, group_role_links, dry_run=batch.dry_run, ) else: outcome = self._reconcile_role( db, command, roles, duplicate_role_keys, used_role_slugs, role_user_links, role_group_links, dry_run=batch.dry_run, ) outcomes.append(outcome) if outcome.status in {"removed", "absent"}: groups.pop(key, None) roles.pop(key, None) if not batch.dry_run: db.flush() return GovernanceProjectionResult( operation_id=batch.operation_id, outcomes=tuple(outcomes), dry_run=batch.dry_run, ) def sync_template(self, session: object, template: GovernanceTemplateMaterialization) -> None: self._legacy_reconcile(session, template, operation="upsert") def remove_template(self, session: object, template: GovernanceTemplateMaterialization) -> None: self._legacy_reconcile(session, template, operation="remove") def _legacy_reconcile( self, session: object, template: GovernanceTemplateMaterialization, *, operation: str, ) -> None: command = GovernanceProjectionCommand( assignment_id=f"legacy:{template.kind}:{template.template_id}:{template.tenant_id}", operation=operation, # type: ignore[arg-type] template=template, provenance={"contract": "access.governanceMaterializer"}, ) result = self.reconcile( session, GovernanceProjectionBatch( operation_id=command.assignment_id, commands=(command,), ), ) if result.blocked: raise AdminConflictError(result.blocked[0].message or "Governance projection was blocked.") def _reconcile_group( self, db: Session, command: GovernanceProjectionCommand, existing: dict[tuple[str, str], Group], duplicate_keys: set[tuple[str, str]], used_slugs: dict[str, set[str]], membership_counts: dict[str, int], role_counts: dict[str, int], *, dry_run: bool, ) -> GovernanceProjectionOutcome: template = command.template key = (template.tenant_id, template.template_id) group = existing.get(key) if key in duplicate_keys: return _outcome( command, status="failed", blocker_codes=("duplicate_managed_projection",), message="Multiple managed groups exist for this template and tenant.", ) if command.operation == "remove": if group is None: return _outcome(command, status="absent") blockers: list[str] = [] if membership_counts.get(group.id, 0): blockers.append("group_has_members") if role_counts.get(group.id, 0): blockers.append("group_has_roles") if blockers: return _outcome( command, status="blocked", resource_id=group.id, blocker_codes=tuple(blockers), message=f"Cannot remove {template.name!r} while its managed group has members or roles.", ) if not dry_run: try: _run_delete_vetoes(db, "group", template.tenant_id, group.id) except AdminConflictError as exc: return _outcome( command, status="blocked", resource_id=group.id, blocker_codes=("module_delete_veto",), message=str(exc), ) db.delete(group) return _outcome(command, status="removed", resource_id=group.id) if group is None: resource_id = new_uuid() slug = _available_slug(used_slugs[template.tenant_id], template.slug) if not dry_run: group = Group( id=resource_id, tenant_id=template.tenant_id, slug=slug, name=template.name, description=template.description, is_active=template.is_active, system_template_id=template.template_id, system_required=template.required, ) db.add(group) existing[key] = group return _outcome(command, status="created", resource_id=resource_id) changes = { "name": template.name, "description": template.description, "system_required": template.required, } if template.required: changes["is_active"] = template.is_active changed = any(getattr(group, field) != value for field, value in changes.items()) if changed and not dry_run: for field, value in changes.items(): setattr(group, field, value) return _outcome(command, status="updated" if changed else "unchanged", resource_id=group.id) def _reconcile_role( self, db: Session, command: GovernanceProjectionCommand, existing: dict[tuple[str, str], Role], duplicate_keys: set[tuple[str, str]], used_slugs: dict[str, set[str]], user_counts: dict[str, int], group_counts: dict[str, int], *, dry_run: bool, ) -> GovernanceProjectionOutcome: template = command.template key = (template.tenant_id, template.template_id) role = existing.get(key) if key in duplicate_keys: return _outcome( command, status="failed", blocker_codes=("duplicate_managed_projection",), message="Multiple managed roles exist for this template and tenant.", ) if command.operation == "remove": if role is None: return _outcome(command, status="absent") blockers: list[str] = [] if user_counts.get(role.id, 0): blockers.append("role_has_users") if group_counts.get(role.id, 0): blockers.append("role_has_groups") if blockers: return _outcome( command, status="blocked", resource_id=role.id, blocker_codes=tuple(blockers), message=f"Cannot remove {template.name!r} while its managed role is assigned to users or groups.", ) if not dry_run: db.delete(role) return _outcome(command, status="removed", resource_id=role.id) if role is None: resource_id = new_uuid() slug = _available_slug(used_slugs[template.tenant_id], template.slug) if not dry_run: role = Role( id=resource_id, tenant_id=template.tenant_id, slug=slug, name=template.name, description=template.description, permissions=list(template.permissions), is_builtin=False, is_assignable=template.is_active, system_template_id=template.template_id, system_required=template.required, ) db.add(role) existing[key] = role return _outcome(command, status="created", resource_id=resource_id) changes: dict[str, object] = { "name": template.name, "description": template.description, "permissions": list(template.permissions), "system_required": template.required, } if template.required: changes["is_assignable"] = template.is_active changed = any(getattr(role, field) != value for field, value in changes.items()) if changed and not dry_run: for field, value in changes.items(): setattr(role, field, value) return _outcome(command, status="updated" if changed else "unchanged", resource_id=role.id) def _managed_groups( session: Session, commands: tuple[GovernanceProjectionCommand, ...], ) -> tuple[dict[tuple[str, str], Group], set[tuple[str, str]]]: if not commands: return {}, set() tenants = {item.template.tenant_id for item in commands} templates = {item.template.template_id for item in commands} rows = session.query(Group).filter( Group.tenant_id.in_(tenants), Group.system_template_id.in_(templates), ).all() return _indexed_managed(rows) def _managed_roles( session: Session, commands: tuple[GovernanceProjectionCommand, ...], ) -> tuple[dict[tuple[str, str], Role], set[tuple[str, str]]]: if not commands: return {}, set() tenants = {item.template.tenant_id for item in commands} templates = {item.template.template_id for item in commands} rows = session.query(Role).filter( Role.tenant_id.in_(tenants), Role.system_template_id.in_(templates), ).all() return _indexed_managed(rows) def _indexed_managed(rows): indexed = {} duplicates = set() for row in rows: key = (row.tenant_id, row.system_template_id) if key in indexed: duplicates.add(key) else: indexed[key] = row return indexed, duplicates def _used_slugs( session: Session, model: type[Group] | type[Role], commands: tuple[GovernanceProjectionCommand, ...], ) -> dict[str, set[str]]: used: dict[str, set[str]] = defaultdict(set) tenants = {item.template.tenant_id for item in commands} if tenants: for tenant_id, slug in session.query(model.tenant_id, model.slug).filter(model.tenant_id.in_(tenants)).all(): used[str(tenant_id)].add(slug) for tenant_id in tenants: used[tenant_id] return used def _assignment_counts(session: Session, model, column, resource_ids: set[str]) -> dict[str, int]: if not resource_ids: return {} return { resource_id: count for resource_id, count in session.query(column, func.count(model.id)) .filter(column.in_(resource_ids)) .group_by(column) .all() } def _available_slug(used: set[str], base: str) -> str: candidate = base suffix = 2 while candidate in used: candidate = f"{base}-{suffix}" suffix += 1 used.add(candidate) return candidate def _outcome( command: GovernanceProjectionCommand, *, status: str, resource_id: str | None = None, blocker_codes: tuple[str, ...] = (), message: str | None = None, ) -> GovernanceProjectionOutcome: template = command.template return GovernanceProjectionOutcome( assignment_id=command.assignment_id, template_id=template.template_id, tenant_id=template.tenant_id, kind=template.kind, operation=command.operation, status=status, # type: ignore[arg-type] resource_id=resource_id, blocker_codes=blocker_codes, message=message, provenance=dict(command.provenance), ) def _session(session: object) -> Session: if not isinstance(session, Session): raise TypeError("Access governance materializer requires a SQLAlchemy Session") return session def _run_delete_vetoes(session: Session, resource_type: str, tenant_id: str, resource_id: str) -> None: registry = get_registry() if registry is None or not hasattr(registry, "delete_veto_providers"): return for provider in registry.delete_veto_providers(resource_type): try: provider(session, tenant_id, resource_id) except AdminConflictError: raise except Exception as exc: raise AdminConflictError(str(exc)) from exc