From 550c14b92e8b6e2b2501fda105eaf4a2fa5506df Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Thu, 20 Aug 2026 19:47:02 +0200 Subject: [PATCH] feat: synchronize governance templates in bulk --- README.md | 6 +- src/govoplan_admin/backend/api/v1/routes.py | 75 +++++- src/govoplan_admin/backend/api/v1/schemas.py | 28 ++ src/govoplan_admin/backend/governance.py | 253 +++++++++++++++--- src/govoplan_admin/backend/manifest.py | 9 +- tests/test_governance_bulk_sync.py | 163 +++++++++++ webui/src/api/admin.ts | 32 +++ .../admin/GovernanceTemplatesPanel.tsx | 23 +- webui/src/i18n/generatedTranslations.ts | 2 + 9 files changed, 550 insertions(+), 41 deletions(-) create mode 100644 tests/test_governance_bulk_sync.py diff --git a/README.md b/README.md index 13473fd..5547e7d 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,11 @@ This repository owns the live `admin_governance_templates` and historical unprefixed governance tables for existing development databases. It contributes the stable `/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 diff --git a/src/govoplan_admin/backend/api/v1/routes.py b/src/govoplan_admin/backend/api/v1/routes.py index c70dcac..3380dbb 100644 --- a/src/govoplan_admin/backend/api/v1/routes.py +++ b/src/govoplan_admin/backend/api/v1/routes.py @@ -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, diff --git a/src/govoplan_admin/backend/api/v1/schemas.py b/src/govoplan_admin/backend/api/v1/schemas.py index c7352eb..55b1307 100644 --- a/src/govoplan_admin/backend/api/v1/schemas.py +++ b/src/govoplan_admin/backend/api/v1/schemas.py @@ -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) diff --git a/src/govoplan_admin/backend/governance.py b/src/govoplan_admin/backend/governance.py index c343936..b5fe120 100644 --- a/src/govoplan_admin/backend/governance.py +++ b/src/govoplan_admin/backend/governance.py @@ -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 diff --git a/src/govoplan_admin/backend/manifest.py b/src/govoplan_admin/backend/manifest.py index ccb8ab8..f5fd30e 100644 --- a/src/govoplan_admin/backend/manifest.py +++ b/src/govoplan_admin/backend/manifest.py @@ -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( diff --git a/tests/test_governance_bulk_sync.py b/tests/test_governance_bulk_sync.py new file mode 100644 index 0000000..89953df --- /dev/null +++ b/tests/test_governance_bulk_sync.py @@ -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() diff --git a/webui/src/api/admin.ts b/webui/src/api/admin.ts index 60e6d27..f021d36 100644 --- a/webui/src/api/admin.ts +++ b/webui/src/api/admin.ts @@ -575,6 +575,27 @@ export type GovernanceTemplateItem = { 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; +}; + +export type GovernanceSynchronizationResponse = { + version: "1"; + operation_id: string; + dry_run: boolean; + outcomes: GovernanceSynchronizationOutcome[]; + counts: Record; +}; + export type DataSubjectSelector = { account_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" }); } +export function synchronizeGovernanceTemplates( + settings: ApiSettings, + templateIds: string[], + dryRun = false +): Promise { + 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 { const response = await apiFetch<{ items: DataSubjectRequestSummary[] }>(settings, "/api/v1/admin/privacy/data-subject-requests"); return response.items; diff --git a/webui/src/features/admin/GovernanceTemplatesPanel.tsx b/webui/src/features/admin/GovernanceTemplatesPanel.tsx index d359ef1..0445b1b 100644 --- a/webui/src/features/admin/GovernanceTemplatesPanel.tsx +++ b/webui/src/features/admin/GovernanceTemplatesPanel.tsx @@ -1,6 +1,6 @@ import { DescriptionItem, DescriptionList } from "@govoplan/core-webui"; 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 { Button } from "@govoplan/core-webui"; import { ConfirmDialog } from "@govoplan/core-webui"; @@ -14,6 +14,7 @@ import { fetchGovernanceTemplates, fetchPermissionCatalog, fetchTenants, + synchronizeGovernanceTemplates, updateGovernanceTemplate, type GovernanceAssignment, type GovernanceTemplateItem, @@ -168,6 +169,23 @@ export default function GovernanceTemplatesPanel({ {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[]>(() => [ { 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) => , onClick: () => setViewing(row) }, { id: "edit", label: i18nMessage("i18n:govoplan-admin.edit_value.fad75899", { value0: row.name }), icon: , disabled: !canWrite, disabledReason: !canWrite ? ADMIN_INTERFACE_I18N.governanceWriteRequired : undefined, onClick: () => openEdit(row) }, + { id: "synchronize", label: "i18n:govoplan-admin.sync.905f6309", icon: , 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: , 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 description = kind === "group" ? diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts index f479d75..0652b66 100644 --- a/webui/src/i18n/generatedTranslations.ts +++ b/webui/src/i18n/generatedTranslations.ts @@ -517,6 +517,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-admin.users.57f2b181": "Users", "i18n:govoplan-admin.valid_after.c615c873": "· Valid after:", "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.b374b8f9": "Valid:", "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.users.57f2b181": "Benutzer", "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.a4aefa35": "Gültig", "i18n:govoplan-admin.valid.b374b8f9": "Valid:",