feat: synchronize governance templates in bulk
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from collections.abc import Mapping
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -8,7 +9,12 @@ from typing import Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_admin.backend.governance import create_template, delete_template, update_template
|
||||
from govoplan_admin.backend.governance import (
|
||||
create_template,
|
||||
delete_template,
|
||||
synchronize_templates,
|
||||
update_template,
|
||||
)
|
||||
from govoplan_core.admin.settings import get_system_settings
|
||||
from govoplan_core.auth import ApiPrincipal, has_scope, require_any_scope, require_scope
|
||||
from govoplan_core.audit.logging import audit_from_principal, audit_operation_context
|
||||
@@ -119,6 +125,8 @@ from .schemas import (
|
||||
GovernanceTemplateListDeltaResponse,
|
||||
GovernanceTemplateListResponse,
|
||||
GovernanceTemplateUpdateRequest,
|
||||
GovernanceSynchronizationRequest,
|
||||
GovernanceSynchronizationResponse,
|
||||
MaintenanceModeItem,
|
||||
ModuleCatalogItem,
|
||||
ModuleCatalogResponse,
|
||||
@@ -2105,6 +2113,71 @@ def create_governance_template(
|
||||
return _governance_template_item(session, item)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/system/governance-templates/synchronize",
|
||||
response_model=GovernanceSynchronizationResponse,
|
||||
)
|
||||
def synchronize_governance_templates(
|
||||
payload: GovernanceSynchronizationRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("system:governance:write")),
|
||||
):
|
||||
try:
|
||||
result = synchronize_templates(
|
||||
session,
|
||||
template_ids=payload.template_ids,
|
||||
dry_run=payload.dry_run,
|
||||
)
|
||||
except (AdminConflictError, AdminValidationError) as exc:
|
||||
raise _http_admin_error(exc) from exc
|
||||
counts = Counter(item.status for item in result.outcomes)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action=(
|
||||
"governance_template.synchronization_previewed"
|
||||
if payload.dry_run
|
||||
else "governance_template.synchronized"
|
||||
),
|
||||
scope="system",
|
||||
object_type="governance_template_batch",
|
||||
object_id=result.operation_id,
|
||||
details={
|
||||
"version": result.version,
|
||||
"dry_run": result.dry_run,
|
||||
"template_ids": list(dict.fromkeys(payload.template_ids)),
|
||||
"counts": dict(sorted(counts.items())),
|
||||
"blocked_assignment_ids": [
|
||||
item.assignment_id
|
||||
for item in result.outcomes
|
||||
if item.status in {"blocked", "failed"}
|
||||
],
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
return GovernanceSynchronizationResponse(
|
||||
version=result.version,
|
||||
operation_id=result.operation_id,
|
||||
dry_run=result.dry_run,
|
||||
outcomes=[
|
||||
{
|
||||
"assignment_id": item.assignment_id,
|
||||
"template_id": item.template_id,
|
||||
"tenant_id": item.tenant_id,
|
||||
"kind": item.kind,
|
||||
"operation": item.operation,
|
||||
"status": item.status,
|
||||
"resource_id": item.resource_id,
|
||||
"blocker_codes": list(item.blocker_codes),
|
||||
"message": item.message,
|
||||
"provenance": dict(item.provenance),
|
||||
}
|
||||
for item in result.outcomes
|
||||
],
|
||||
counts=dict(sorted(counts.items())),
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/system/governance-templates/{template_id}", response_model=GovernanceTemplateItem)
|
||||
def update_governance_template(
|
||||
template_id: str,
|
||||
|
||||
@@ -590,3 +590,31 @@ class GovernanceTemplateUpdateRequest(BaseModel):
|
||||
is_active: bool = True
|
||||
assignments: list[GovernanceAssignment] = Field(default_factory=list)
|
||||
change_request_id: str | None = None
|
||||
|
||||
|
||||
class GovernanceSynchronizationRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
template_ids: list[str] = Field(min_length=1, max_length=100)
|
||||
dry_run: bool = False
|
||||
|
||||
|
||||
class GovernanceSynchronizationOutcome(BaseModel):
|
||||
assignment_id: str
|
||||
template_id: str
|
||||
tenant_id: str
|
||||
kind: Literal["group", "role"]
|
||||
operation: Literal["upsert", "remove"]
|
||||
status: Literal["created", "updated", "unchanged", "removed", "absent", "blocked", "failed"]
|
||||
resource_id: str | None = None
|
||||
blocker_codes: list[str] = Field(default_factory=list)
|
||||
message: str | None = None
|
||||
provenance: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class GovernanceSynchronizationResponse(BaseModel):
|
||||
version: Literal["1"] = "1"
|
||||
operation_id: str
|
||||
dry_run: bool
|
||||
outcomes: list[GovernanceSynchronizationOutcome]
|
||||
counts: dict[str, int] = Field(default_factory=dict)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -110,7 +110,9 @@ manifest = ModuleManifest(
|
||||
id="admin.governance-and-module-lifecycle",
|
||||
title="Govern configuration and module lifecycle",
|
||||
summary="Admin owns reusable governance templates, configuration packages, and the operator-facing module lifecycle queue.",
|
||||
body="Configuration packages import or export module-owned configuration; they do not install software. Module catalog actions create reviewed install, update, activation, deactivation, or retirement requests for the trusted installer process. Governance templates materialize approved role and group structures through the owning Access contracts.",
|
||||
body=(
|
||||
"Configuration packages import or export module-owned configuration; they do not install software. Module catalog actions create reviewed install, update, activation, deactivation, or retirement requests for the trusted installer process. Governance templates materialize approved role and group structures through the owning Access contracts. Template assignment validation bulk-loads the selected tenants before mutation, and synchronization delegates one bounded versioned batch to Access instead of issuing per-tenant calls. Operators may preview or apply synchronization for up to 100 selected templates and 500 assignments. Every assignment returns an explicit outcome and provenance; missing tenants, protected memberships, role mappings, and module vetoes remain visible as blockers rather than being skipped. Retries are idempotent and preview/application runs are audited."
|
||||
),
|
||||
documentation_types=("admin",),
|
||||
audience=("system_admin", "operator", "module_admin"),
|
||||
related_modules=("access", "audit", "ops"),
|
||||
@@ -122,6 +124,11 @@ manifest = ModuleManifest(
|
||||
"admin.configuration-packages",
|
||||
"admin.governance-templates",
|
||||
],
|
||||
"api_paths": [
|
||||
"/api/v1/admin/system/governance-templates",
|
||||
"/api/v1/admin/system/governance-templates/synchronize",
|
||||
],
|
||||
"synchronization_limits": {"templates": 100, "assignments": 500},
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
|
||||
Reference in New Issue
Block a user