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
+5 -1
View File
@@ -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
+74 -1
View File
@@ -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)
+217 -36
View File
@@ -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 = [
_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: def sync_template(session: Session, item: GovernanceTemplate) -> GovernanceProjectionResult:
assignments = session.query(GovernanceTemplateAssignment).filter( return synchronize_templates(session, template_ids=(item.id,), dry_run=False)
GovernanceTemplateAssignment.template_id == item.id
).all()
for assignment in assignments: def synchronize_templates(
required = assignment.mode == "required" session: Session,
_governance_materializer().sync_template( *,
session, template_ids: Iterable[str],
_materialization(item, tenant_id=assignment.tenant_id, required=required), 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( 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
+8 -1
View File
@@ -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(
+163
View File
@@ -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()
+32
View File
@@ -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" ?
+2
View File
@@ -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:",