feat: synchronize governance templates in bulk
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user