feat: synchronize governance templates in bulk
This commit is contained in:
@@ -16,7 +16,11 @@ This repository owns the live `admin_governance_templates` and
|
|||||||
historical unprefixed governance tables for existing development databases. It
|
historical unprefixed governance tables for existing development databases. It
|
||||||
contributes the stable
|
contributes the stable
|
||||||
`/api/v1/admin/system/governance-templates` routes and owns governance-template
|
`/api/v1/admin/system/governance-templates` routes and owns governance-template
|
||||||
CRUD plus materialization into access-owned tenant groups and roles.
|
CRUD plus materialization into access-owned tenant groups and roles. The
|
||||||
|
`/synchronize` operation validates selected templates and tenants in bounded
|
||||||
|
bulk reads, delegates one versioned projection batch to Access, and returns an
|
||||||
|
auditable outcome for every assignment. Dry runs do not mutate Access data;
|
||||||
|
applied retries are idempotent and never silently skip blocked assignments.
|
||||||
|
|
||||||
## WebUI Package
|
## WebUI Package
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import Counter
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -8,7 +9,12 @@ from typing import Any
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||||
from sqlalchemy.orm import Session
|
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.admin.settings import get_system_settings
|
||||||
from govoplan_core.auth import ApiPrincipal, has_scope, require_any_scope, require_scope
|
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
|
from govoplan_core.audit.logging import audit_from_principal, audit_operation_context
|
||||||
@@ -119,6 +125,8 @@ from .schemas import (
|
|||||||
GovernanceTemplateListDeltaResponse,
|
GovernanceTemplateListDeltaResponse,
|
||||||
GovernanceTemplateListResponse,
|
GovernanceTemplateListResponse,
|
||||||
GovernanceTemplateUpdateRequest,
|
GovernanceTemplateUpdateRequest,
|
||||||
|
GovernanceSynchronizationRequest,
|
||||||
|
GovernanceSynchronizationResponse,
|
||||||
MaintenanceModeItem,
|
MaintenanceModeItem,
|
||||||
ModuleCatalogItem,
|
ModuleCatalogItem,
|
||||||
ModuleCatalogResponse,
|
ModuleCatalogResponse,
|
||||||
@@ -2105,6 +2113,71 @@ def create_governance_template(
|
|||||||
return _governance_template_item(session, item)
|
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)
|
@router.patch("/system/governance-templates/{template_id}", response_model=GovernanceTemplateItem)
|
||||||
def update_governance_template(
|
def update_governance_template(
|
||||||
template_id: str,
|
template_id: str,
|
||||||
|
|||||||
@@ -590,3 +590,31 @@ class GovernanceTemplateUpdateRequest(BaseModel):
|
|||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
assignments: list[GovernanceAssignment] = Field(default_factory=list)
|
assignments: list[GovernanceAssignment] = Field(default_factory=list)
|
||||||
change_request_id: str | None = None
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_admin.backend.db.models import GovernanceTemplate, GovernanceTemplateAssignment
|
from govoplan_admin.backend.db.models import GovernanceTemplate, GovernanceTemplateAssignment
|
||||||
from govoplan_core.admin.common import AdminConflictError, AdminValidationError, slugify
|
from govoplan_core.admin.common import AdminConflictError, AdminValidationError, slugify
|
||||||
from govoplan_core.core.access import (
|
from govoplan_core.core.access import (
|
||||||
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER,
|
CAPABILITY_ACCESS_GOVERNANCE_PROJECTION_V1,
|
||||||
AccessGovernanceMaterializer,
|
AccessGovernanceProjectionV1,
|
||||||
|
GovernanceProjectionBatch,
|
||||||
|
GovernanceProjectionCommand,
|
||||||
|
GovernanceProjectionOutcome,
|
||||||
|
GovernanceProjectionResult,
|
||||||
GovernanceTemplateMaterialization,
|
GovernanceTemplateMaterialization,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.runtime import get_registry
|
from govoplan_core.core.runtime import get_registry
|
||||||
@@ -15,15 +22,17 @@ from govoplan_core.tenancy.scope import Tenant
|
|||||||
|
|
||||||
TEMPLATE_KINDS = {"group", "role"}
|
TEMPLATE_KINDS = {"group", "role"}
|
||||||
ASSIGNMENT_MODES = {"available", "required"}
|
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()
|
registry = get_registry()
|
||||||
if registry is None or not registry.has_capability(CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER):
|
if registry is None or not registry.has_capability(CAPABILITY_ACCESS_GOVERNANCE_PROJECTION_V1):
|
||||||
raise AdminValidationError("Access governance materializer capability is not configured.")
|
raise AdminValidationError("Access governance projection v1 capability is not configured.")
|
||||||
capability = registry.require_capability(CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER)
|
capability = registry.require_capability(CAPABILITY_ACCESS_GOVERNANCE_PROJECTION_V1)
|
||||||
if not isinstance(capability, AccessGovernanceMaterializer):
|
if not isinstance(capability, AccessGovernanceProjectionV1):
|
||||||
raise AdminValidationError("Access governance materializer capability is invalid.")
|
raise AdminValidationError("Access governance projection v1 capability is invalid.")
|
||||||
return capability
|
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]:
|
def validate_template(kind: str, permissions: list[str]) -> list[str]:
|
||||||
if kind not in TEMPLATE_KINDS:
|
if kind not in TEMPLATE_KINDS:
|
||||||
raise AdminValidationError("Template kind must be group or role.")
|
raise AdminValidationError("Template kind must be group or role.")
|
||||||
@@ -107,7 +177,6 @@ def update_template(
|
|||||||
item.permissions = validate_template(item.kind, permissions)
|
item.permissions = validate_template(item.kind, permissions)
|
||||||
item.is_active = is_active
|
item.is_active = is_active
|
||||||
set_template_assignments(session, item, assignments)
|
set_template_assignments(session, item, assignments)
|
||||||
sync_template(session, item)
|
|
||||||
return item
|
return item
|
||||||
|
|
||||||
|
|
||||||
@@ -115,55 +184,167 @@ def set_template_assignments(
|
|||||||
session: Session,
|
session: Session,
|
||||||
item: GovernanceTemplate,
|
item: GovernanceTemplate,
|
||||||
assignments: list[dict[str, str]],
|
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] = {}
|
desired: dict[str, str] = {}
|
||||||
for assignment in assignments:
|
for assignment in assignments:
|
||||||
tenant_id = assignment.get("tenant_id", "")
|
tenant_id = assignment.get("tenant_id", "")
|
||||||
mode = assignment.get("mode", "available")
|
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:
|
if mode not in ASSIGNMENT_MODES:
|
||||||
raise AdminValidationError("Template assignment mode must be available or required.")
|
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
|
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 = {
|
existing = {
|
||||||
row.tenant_id: row
|
row.tenant_id: row
|
||||||
for row in session.query(GovernanceTemplateAssignment)
|
for row in session.query(GovernanceTemplateAssignment)
|
||||||
.filter(GovernanceTemplateAssignment.template_id == item.id)
|
.filter(GovernanceTemplateAssignment.template_id == item.id)
|
||||||
.all()
|
.all()
|
||||||
}
|
}
|
||||||
for tenant_id, row in list(existing.items()):
|
removed = [row for tenant_id, row in existing.items() if tenant_id not in desired]
|
||||||
if tenant_id in desired:
|
retained: list[GovernanceTemplateAssignment] = []
|
||||||
row.mode = desired[tenant_id]
|
|
||||||
continue
|
|
||||||
_governance_materializer().remove_template(session, _materialization(item, tenant_id=tenant_id))
|
|
||||||
session.delete(row)
|
|
||||||
|
|
||||||
for tenant_id, mode in desired.items():
|
for tenant_id, mode in desired.items():
|
||||||
if tenant_id not in existing:
|
row = existing.get(tenant_id)
|
||||||
session.add(GovernanceTemplateAssignment(template_id=item.id, tenant_id=tenant_id, mode=mode))
|
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()
|
session.flush()
|
||||||
sync_template(session, item)
|
|
||||||
|
|
||||||
|
commands = [
|
||||||
def sync_template(session: Session, item: GovernanceTemplate) -> None:
|
_command(item, row, operation="remove", source="admin.assignment-reconciliation")
|
||||||
assignments = session.query(GovernanceTemplateAssignment).filter(
|
for row in removed
|
||||||
GovernanceTemplateAssignment.template_id == item.id
|
]
|
||||||
).all()
|
commands.extend(
|
||||||
for assignment in assignments:
|
_command(item, row, operation="upsert", source="admin.assignment-reconciliation")
|
||||||
required = assignment.mode == "required"
|
for row in retained
|
||||||
_governance_materializer().sync_template(
|
|
||||||
session,
|
|
||||||
_materialization(item, tenant_id=assignment.tenant_id, required=required),
|
|
||||||
)
|
)
|
||||||
|
result = _reconcile(
|
||||||
|
session,
|
||||||
|
commands,
|
||||||
|
source="assignment reconciliation",
|
||||||
|
)
|
||||||
|
_raise_blocked(result)
|
||||||
|
for row in removed:
|
||||||
|
session.delete(row)
|
||||||
session.flush()
|
session.flush()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def delete_template(session: Session, item: GovernanceTemplate) -> None:
|
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(
|
assignments = session.query(GovernanceTemplateAssignment).filter(
|
||||||
GovernanceTemplateAssignment.template_id == item.id
|
GovernanceTemplateAssignment.template_id == item.id
|
||||||
).all()
|
).all()
|
||||||
for assignment in assignments:
|
result = _reconcile(
|
||||||
_governance_materializer().remove_template(session, _materialization(item, tenant_id=assignment.tenant_id))
|
session,
|
||||||
|
(
|
||||||
|
_command(item, assignment, operation="remove", source="admin.template-deletion")
|
||||||
|
for assignment in assignments
|
||||||
|
),
|
||||||
|
source="template deletion",
|
||||||
|
)
|
||||||
|
_raise_blocked(result)
|
||||||
session.delete(item)
|
session.delete(item)
|
||||||
|
return result
|
||||||
|
|||||||
@@ -110,7 +110,9 @@ manifest = ModuleManifest(
|
|||||||
id="admin.governance-and-module-lifecycle",
|
id="admin.governance-and-module-lifecycle",
|
||||||
title="Govern configuration and module lifecycle",
|
title="Govern configuration and module lifecycle",
|
||||||
summary="Admin owns reusable governance templates, configuration packages, and the operator-facing module lifecycle queue.",
|
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",),
|
documentation_types=("admin",),
|
||||||
audience=("system_admin", "operator", "module_admin"),
|
audience=("system_admin", "operator", "module_admin"),
|
||||||
related_modules=("access", "audit", "ops"),
|
related_modules=("access", "audit", "ops"),
|
||||||
@@ -122,6 +124,11 @@ manifest = ModuleManifest(
|
|||||||
"admin.configuration-packages",
|
"admin.configuration-packages",
|
||||||
"admin.governance-templates",
|
"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(
|
DocumentationTopic(
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, event
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from govoplan_admin.backend.db.models import GovernanceTemplate, GovernanceTemplateAssignment
|
||||||
|
from govoplan_admin.backend.governance import set_template_assignments, synchronize_templates
|
||||||
|
from govoplan_core.core.access import GovernanceProjectionOutcome, GovernanceProjectionResult
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.tenancy.scope import Tenant, create_scope_tables, scope_registry
|
||||||
|
|
||||||
|
|
||||||
|
class _Projection:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.batches = []
|
||||||
|
|
||||||
|
def reconcile(self, session: object, batch):
|
||||||
|
del session
|
||||||
|
self.batches.append(batch)
|
||||||
|
return GovernanceProjectionResult(
|
||||||
|
operation_id=batch.operation_id,
|
||||||
|
dry_run=batch.dry_run,
|
||||||
|
outcomes=tuple(
|
||||||
|
GovernanceProjectionOutcome(
|
||||||
|
assignment_id=command.assignment_id,
|
||||||
|
template_id=command.template.template_id,
|
||||||
|
tenant_id=command.template.tenant_id,
|
||||||
|
kind=command.template.kind,
|
||||||
|
operation=command.operation,
|
||||||
|
status=("removed" if command.operation == "remove" else "created"),
|
||||||
|
resource_id=f"resource-{command.assignment_id}",
|
||||||
|
provenance=dict(command.provenance),
|
||||||
|
)
|
||||||
|
for command in batch.commands
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
def __init__(self, projection: _Projection) -> None:
|
||||||
|
self.projection = projection
|
||||||
|
|
||||||
|
def has_capability(self, name: str) -> bool:
|
||||||
|
return name == "access.governanceProjection.v1"
|
||||||
|
|
||||||
|
def require_capability(self, name: str) -> object:
|
||||||
|
if not self.has_capability(name):
|
||||||
|
raise KeyError(name)
|
||||||
|
return self.projection
|
||||||
|
|
||||||
|
|
||||||
|
class GovernanceBulkSyncTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
|
create_scope_tables(self.engine)
|
||||||
|
Base.metadata.create_all(bind=self.engine)
|
||||||
|
self.Session = sessionmaker(bind=self.engine)
|
||||||
|
self.session = self.Session()
|
||||||
|
self.projection = _Projection()
|
||||||
|
self.registry_patch = patch(
|
||||||
|
"govoplan_admin.backend.governance.get_registry",
|
||||||
|
return_value=_Registry(self.projection),
|
||||||
|
)
|
||||||
|
self.registry_patch.start()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.registry_patch.stop()
|
||||||
|
self.session.close()
|
||||||
|
Base.metadata.drop_all(bind=self.engine)
|
||||||
|
scope_registry.metadata.drop_all(bind=self.engine)
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def _template(self) -> GovernanceTemplate:
|
||||||
|
item = GovernanceTemplate(
|
||||||
|
id="template-1",
|
||||||
|
kind="role",
|
||||||
|
slug="reviewer",
|
||||||
|
name="Reviewer",
|
||||||
|
permissions=["access:role:read"],
|
||||||
|
)
|
||||||
|
self.session.add(item)
|
||||||
|
self.session.flush()
|
||||||
|
return item
|
||||||
|
|
||||||
|
def test_assignment_validation_and_projection_use_bounded_reads(self) -> None:
|
||||||
|
item = self._template()
|
||||||
|
tenants = [
|
||||||
|
Tenant(id=f"tenant-{index}", slug=f"tenant-{index}", name=f"Tenant {index}")
|
||||||
|
for index in range(200)
|
||||||
|
]
|
||||||
|
self.session.add_all(tenants)
|
||||||
|
self.session.commit()
|
||||||
|
self.session.refresh(item)
|
||||||
|
select_count = 0
|
||||||
|
|
||||||
|
def record_select(_conn, _cursor, statement, _parameters, _context, _executemany):
|
||||||
|
nonlocal select_count
|
||||||
|
if statement.lstrip().upper().startswith("SELECT"):
|
||||||
|
select_count += 1
|
||||||
|
|
||||||
|
event.listen(self.engine, "before_cursor_execute", record_select)
|
||||||
|
try:
|
||||||
|
result = set_template_assignments(
|
||||||
|
self.session,
|
||||||
|
item,
|
||||||
|
[
|
||||||
|
{"tenant_id": f"tenant-{index}", "mode": "required" if index % 2 else "available"}
|
||||||
|
for index in range(len(tenants))
|
||||||
|
],
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
event.remove(self.engine, "before_cursor_execute", record_select)
|
||||||
|
|
||||||
|
self.assertEqual(2, select_count)
|
||||||
|
self.assertEqual(200, len(result.outcomes))
|
||||||
|
self.assertEqual(1, len(self.projection.batches))
|
||||||
|
self.assertEqual(200, len(self.projection.batches[0].commands))
|
||||||
|
self.assertEqual(200, self.session.query(GovernanceTemplateAssignment).count())
|
||||||
|
|
||||||
|
def test_bulk_synchronization_reports_stale_tenant_without_skipping_valid_assignment(self) -> None:
|
||||||
|
item = self._template()
|
||||||
|
self.session.add(Tenant(id="tenant-valid", slug="valid", name="Valid"))
|
||||||
|
self.session.add_all(
|
||||||
|
[
|
||||||
|
GovernanceTemplateAssignment(
|
||||||
|
id="assignment-valid",
|
||||||
|
template_id=item.id,
|
||||||
|
tenant_id="tenant-valid",
|
||||||
|
mode="required",
|
||||||
|
),
|
||||||
|
GovernanceTemplateAssignment(
|
||||||
|
id="assignment-stale",
|
||||||
|
template_id=item.id,
|
||||||
|
tenant_id="tenant-missing",
|
||||||
|
mode="available",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
result = synchronize_templates(
|
||||||
|
self.session,
|
||||||
|
template_ids=(item.id,),
|
||||||
|
dry_run=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
["blocked", "created"],
|
||||||
|
sorted(item.status for item in result.outcomes),
|
||||||
|
)
|
||||||
|
stale = next(item for item in result.outcomes if item.assignment_id == "assignment-stale")
|
||||||
|
self.assertEqual(("unknown_tenant",), stale.blocker_codes)
|
||||||
|
self.assertEqual(
|
||||||
|
["assignment-valid"],
|
||||||
|
[command.assignment_id for command in self.projection.batches[-1].commands],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -575,6 +575,27 @@ export type GovernanceTemplateItem = {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type GovernanceSynchronizationOutcome = {
|
||||||
|
assignment_id: string;
|
||||||
|
template_id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
kind: "group" | "role";
|
||||||
|
operation: "upsert" | "remove";
|
||||||
|
status: "created" | "updated" | "unchanged" | "removed" | "absent" | "blocked" | "failed";
|
||||||
|
resource_id?: string | null;
|
||||||
|
blocker_codes: string[];
|
||||||
|
message?: string | null;
|
||||||
|
provenance: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GovernanceSynchronizationResponse = {
|
||||||
|
version: "1";
|
||||||
|
operation_id: string;
|
||||||
|
dry_run: boolean;
|
||||||
|
outcomes: GovernanceSynchronizationOutcome[];
|
||||||
|
counts: Record<string, number>;
|
||||||
|
};
|
||||||
|
|
||||||
export type DataSubjectSelector = {
|
export type DataSubjectSelector = {
|
||||||
account_id?: string | null;
|
account_id?: string | null;
|
||||||
identity_id?: string | null;
|
identity_id?: string | null;
|
||||||
@@ -819,6 +840,17 @@ export function deleteGovernanceTemplate(settings: ApiSettings, templateId: stri
|
|||||||
return apiFetch(settings, apiPath(`/api/v1/admin/system/governance-templates/${templateId}`, { change_request_id: changeRequestId }), { method: "DELETE" });
|
return apiFetch(settings, apiPath(`/api/v1/admin/system/governance-templates/${templateId}`, { change_request_id: changeRequestId }), { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function synchronizeGovernanceTemplates(
|
||||||
|
settings: ApiSettings,
|
||||||
|
templateIds: string[],
|
||||||
|
dryRun = false
|
||||||
|
): Promise<GovernanceSynchronizationResponse> {
|
||||||
|
return apiFetch(settings, "/api/v1/admin/system/governance-templates/synchronize", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ template_ids: templateIds, dry_run: dryRun })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchDataSubjectRequests(settings: ApiSettings): Promise<DataSubjectRequestSummary[]> {
|
export async function fetchDataSubjectRequests(settings: ApiSettings): Promise<DataSubjectRequestSummary[]> {
|
||||||
const response = await apiFetch<{ items: DataSubjectRequestSummary[] }>(settings, "/api/v1/admin/privacy/data-subject-requests");
|
const response = await apiFetch<{ items: DataSubjectRequestSummary[] }>(settings, "/api/v1/admin/privacy/data-subject-requests");
|
||||||
return response.items;
|
return response.items;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { DescriptionItem, DescriptionList } from "@govoplan/core-webui";
|
import { DescriptionItem, DescriptionList } from "@govoplan/core-webui";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Search, Pencil, Plus, Trash2 } from "lucide-react";
|
import { Search, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||||
import type { FormGrid, ApiSettings } from "@govoplan/core-webui";
|
import type { FormGrid, ApiSettings } from "@govoplan/core-webui";
|
||||||
import { Button } from "@govoplan/core-webui";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
fetchGovernanceTemplates,
|
fetchGovernanceTemplates,
|
||||||
fetchPermissionCatalog,
|
fetchPermissionCatalog,
|
||||||
fetchTenants,
|
fetchTenants,
|
||||||
|
synchronizeGovernanceTemplates,
|
||||||
updateGovernanceTemplate,
|
updateGovernanceTemplate,
|
||||||
type GovernanceAssignment,
|
type GovernanceAssignment,
|
||||||
type GovernanceTemplateItem,
|
type GovernanceTemplateItem,
|
||||||
@@ -168,6 +169,23 @@ export default function GovernanceTemplatesPanel({
|
|||||||
{setBusy(false);}
|
{setBusy(false);}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function synchronize(item: GovernanceTemplateItem) {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const result = await synchronizeGovernanceTemplates(settings, [item.id]);
|
||||||
|
const blocked = result.outcomes.filter((outcome) => outcome.status === "blocked" || outcome.status === "failed");
|
||||||
|
if (blocked.length) {
|
||||||
|
setError(blocked.map((outcome) => outcome.message || outcome.blocker_codes.join(", ")).join("; "));
|
||||||
|
} else {
|
||||||
|
setSuccess(i18nMessage("i18n:govoplan-admin.value_updated_and_synchronized_to_assigned_tenan.d136eef2", { value0: item.name }));
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
await onAuthRefresh();
|
||||||
|
} catch (err) {setError(adminErrorMessage(err));} finally
|
||||||
|
{setBusy(false);}
|
||||||
|
}
|
||||||
|
|
||||||
const columns = useMemo<DataGridColumn<GovernanceTemplateItem>[]>(() => [
|
const columns = useMemo<DataGridColumn<GovernanceTemplateItem>[]>(() => [
|
||||||
{
|
{
|
||||||
id: "template", header: kind === "group" ? "i18n:govoplan-admin.group_template.973e0fa6" : "i18n:govoplan-admin.tenant_role.6b53115d", width: "minmax(240px, 1.2fr)", minWidth: 210, resizable: true, sticky: "start", sortable: true, filterable: true,
|
id: "template", header: kind === "group" ? "i18n:govoplan-admin.group_template.973e0fa6" : "i18n:govoplan-admin.tenant_role.6b53115d", width: "minmax(240px, 1.2fr)", minWidth: 210, resizable: true, sticky: "start", sortable: true, filterable: true,
|
||||||
@@ -192,10 +210,11 @@ export default function GovernanceTemplatesPanel({
|
|||||||
render: (row) => <TableActionGroup actions={[
|
render: (row) => <TableActionGroup actions={[
|
||||||
{ id: "inspect", label: i18nMessage("i18n:govoplan-admin.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
|
{ id: "inspect", label: i18nMessage("i18n:govoplan-admin.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
|
||||||
{ id: "edit", label: i18nMessage("i18n:govoplan-admin.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, disabled: !canWrite, disabledReason: !canWrite ? ADMIN_INTERFACE_I18N.governanceWriteRequired : undefined, onClick: () => openEdit(row) },
|
{ id: "edit", label: i18nMessage("i18n:govoplan-admin.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, disabled: !canWrite, disabledReason: !canWrite ? ADMIN_INTERFACE_I18N.governanceWriteRequired : undefined, onClick: () => openEdit(row) },
|
||||||
|
{ id: "synchronize", label: "i18n:govoplan-admin.sync.905f6309", icon: <RefreshCw />, disabled: !canWrite || busy, disabledReason: !canWrite ? ADMIN_INTERFACE_I18N.governanceWriteRequired : busy ? ADMIN_INTERFACE_I18N.busy : undefined, onClick: () => void synchronize(row) },
|
||||||
{ id: "delete", label: i18nMessage("i18n:govoplan-admin.delete_value.4d18989e", { value0: row.name }), icon: <Trash2 />, variant: "danger", disabled: !canWrite, disabledReason: !canWrite ? ADMIN_INTERFACE_I18N.governanceWriteRequired : undefined, onClick: () => setDeleting(row) }
|
{ id: "delete", label: i18nMessage("i18n:govoplan-admin.delete_value.4d18989e", { value0: row.name }), icon: <Trash2 />, variant: "danger", disabled: !canWrite, disabledReason: !canWrite ? ADMIN_INTERFACE_I18N.governanceWriteRequired : undefined, onClick: () => setDeleting(row) }
|
||||||
]} />
|
]} />
|
||||||
}],
|
}],
|
||||||
[canWrite, kind, tenants]);
|
[busy, canWrite, kind, tenants]);
|
||||||
|
|
||||||
const title = kind === "group" ? "i18n:govoplan-admin.central_groups.5c9b5b66" : "i18n:govoplan-admin.tenant_roles.51aca82d";
|
const title = kind === "group" ? "i18n:govoplan-admin.central_groups.5c9b5b66" : "i18n:govoplan-admin.tenant_roles.51aca82d";
|
||||||
const description = kind === "group" ?
|
const description = kind === "group" ?
|
||||||
|
|||||||
@@ -517,6 +517,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-admin.users.57f2b181": "Users",
|
"i18n:govoplan-admin.users.57f2b181": "Users",
|
||||||
"i18n:govoplan-admin.valid_after.c615c873": "· Valid after:",
|
"i18n:govoplan-admin.valid_after.c615c873": "· Valid after:",
|
||||||
"i18n:govoplan-admin.valid_and_trusted.73cf89bc": "Valid and trusted",
|
"i18n:govoplan-admin.valid_and_trusted.73cf89bc": "Valid and trusted",
|
||||||
|
"i18n:govoplan-admin.sync.905f6309": "Sync",
|
||||||
"i18n:govoplan-admin.valid.a4aefa35": "Valid",
|
"i18n:govoplan-admin.valid.a4aefa35": "Valid",
|
||||||
"i18n:govoplan-admin.valid.b374b8f9": "Valid:",
|
"i18n:govoplan-admin.valid.b374b8f9": "Valid:",
|
||||||
"i18n:govoplan-admin.value_deleted.3c4bf574": "{value0} deleted.",
|
"i18n:govoplan-admin.value_deleted.3c4bf574": "{value0} deleted.",
|
||||||
@@ -1052,6 +1053,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-admin.user_retention_policy_limits": "Aufbewahrungslimits fuer benutzereigene Datensaetze.",
|
"i18n:govoplan-admin.user_retention_policy_limits": "Aufbewahrungslimits fuer benutzereigene Datensaetze.",
|
||||||
"i18n:govoplan-admin.users.57f2b181": "Benutzer",
|
"i18n:govoplan-admin.users.57f2b181": "Benutzer",
|
||||||
"i18n:govoplan-admin.valid_after.c615c873": "· Valid after:",
|
"i18n:govoplan-admin.valid_after.c615c873": "· Valid after:",
|
||||||
|
"i18n:govoplan-admin.sync.905f6309": "Synchronisieren",
|
||||||
"i18n:govoplan-admin.valid_and_trusted.73cf89bc": "Valid and trusted",
|
"i18n:govoplan-admin.valid_and_trusted.73cf89bc": "Valid and trusted",
|
||||||
"i18n:govoplan-admin.valid.a4aefa35": "Gültig",
|
"i18n:govoplan-admin.valid.a4aefa35": "Gültig",
|
||||||
"i18n:govoplan-admin.valid.b374b8f9": "Valid:",
|
"i18n:govoplan-admin.valid.b374b8f9": "Valid:",
|
||||||
|
|||||||
Reference in New Issue
Block a user