feat: synchronize governance templates in bulk

This commit is contained in:
2026-08-20 19:47:02 +02:00
parent 935cee8ccd
commit 550c14b92e
9 changed files with 550 additions and 41 deletions
+217 -36
View File
@@ -1,12 +1,19 @@
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_MATERIALIZER,
AccessGovernanceMaterializer,
CAPABILITY_ACCESS_GOVERNANCE_PROJECTION_V1,
AccessGovernanceProjectionV1,
GovernanceProjectionBatch,
GovernanceProjectionCommand,
GovernanceProjectionOutcome,
GovernanceProjectionResult,
GovernanceTemplateMaterialization,
)
from govoplan_core.core.runtime import get_registry
@@ -15,15 +22,17 @@ 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_materializer() -> AccessGovernanceMaterializer:
def _governance_projection() -> AccessGovernanceProjectionV1:
registry = get_registry()
if registry is None or not registry.has_capability(CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER):
raise AdminValidationError("Access governance materializer capability is not configured.")
capability = registry.require_capability(CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER)
if not isinstance(capability, AccessGovernanceMaterializer):
raise AdminValidationError("Access governance materializer capability is invalid.")
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
@@ -46,6 +55,67 @@ def _materialization(
)
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.")
@@ -107,7 +177,6 @@ def update_template(
item.permissions = validate_template(item.kind, permissions)
item.is_active = is_active
set_template_assignments(session, item, assignments)
sync_template(session, item)
return item
@@ -115,55 +184,167 @@ def set_template_assignments(
session: Session,
item: GovernanceTemplate,
assignments: list[dict[str, str]],
) -> None:
) -> 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.")
tenant = session.get(Tenant, tenant_id)
if tenant is None:
raise AdminValidationError(f"Unknown tenant: {tenant_id}")
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()
}
for tenant_id, row in list(existing.items()):
if tenant_id in desired:
row.mode = desired[tenant_id]
continue
_governance_materializer().remove_template(session, _materialization(item, tenant_id=tenant_id))
session.delete(row)
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():
if tenant_id not in existing:
session.add(GovernanceTemplateAssignment(template_id=item.id, tenant_id=tenant_id, mode=mode))
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()
sync_template(session, item)
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) -> None:
assignments = session.query(GovernanceTemplateAssignment).filter(
GovernanceTemplateAssignment.template_id == item.id
).all()
for assignment in assignments:
required = assignment.mode == "required"
_governance_materializer().sync_template(
session,
_materialization(item, tenant_id=assignment.tenant_id, required=required),
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."
)
session.flush()
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) -> None:
def delete_template(session: Session, item: GovernanceTemplate) -> GovernanceProjectionResult:
assignments = session.query(GovernanceTemplateAssignment).filter(
GovernanceTemplateAssignment.template_id == item.id
).all()
for assignment in assignments:
_governance_materializer().remove_template(session, _materialization(item, tenant_id=assignment.tenant_id))
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