diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5fd4061 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.venv/ +build/ +dist/ +node_modules/ +webui/node_modules/ +webui/dist/ +*.tsbuildinfo +.component-test-build/ +.module-test-build/ +.policy-test-build/ +.template-preview-test-build/ +.import-test-build/ +webui/.component-test-build/ +webui/.module-test-build/ +webui/.policy-test-build/ +webui/.template-preview-test-build/ +webui/.import-test-build/ diff --git a/README.md b/README.md index 3d503aa..be309e8 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,10 @@ `govoplan-admin` owns generic system administration API and WebUI contributions during the GovOPlaN module split. -This repository owns the live `governance_templates` and -`governance_template_assignments` tables while preserving their historical -table names. It contributes the stable +This repository owns the live `admin_governance_templates` and +`admin_governance_template_assignments` tables. Core migrations rename the +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. @@ -23,3 +24,22 @@ section entries through core's `admin.sections` UI capability: The route shell in access collects those sections at runtime, applies their scope requirements, and renders them without importing admin package internals. + +## Module Lifecycle Administration + +The admin module owns the operator surfaces for module lifecycle management: + +- installed/enabled/desired module state +- runtime activation and deactivation of installed modules +- signed catalog install planning +- non-destructive uninstall planning, with explicit `destroy_data` retirement + options where a module provides a retirement provider +- installer preflight status, maintenance-mode blockers, migration/restart + checklist entries, and rendered operator commands +- installer daemon request queue, cancellation/retry, recent run summaries, + rollback status, run IDs, request IDs, and trace IDs + +Package mutation is intentionally not executed inside the FastAPI request. The +admin UI records operator intent and queues or renders commands for the trusted +installer process described in +`/mnt/DATA/git/govoplan-core/docs/MODULE_ARCHITECTURE.md`. diff --git a/package.json b/package.json index 95d7649..e2199f5 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ ], "peerDependencies": { "@govoplan/core-webui": "^0.1.6", - "lucide-react": "^0.555.0", + "lucide-react": "^1.23.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.1.1" diff --git a/src/govoplan_admin.egg-info/PKG-INFO b/src/govoplan_admin.egg-info/PKG-INFO deleted file mode 100644 index 420a4a8..0000000 --- a/src/govoplan_admin.egg-info/PKG-INFO +++ /dev/null @@ -1,35 +0,0 @@ -Metadata-Version: 2.4 -Name: govoplan-admin -Version: 0.1.4 -Summary: GovOPlaN generic administration module. -Author: GovOPlaN -Requires-Python: >=3.12 -Description-Content-Type: text/markdown -Requires-Dist: govoplan-core>=0.1.4 -Requires-Dist: govoplan-access>=0.1.4 - -# GovOPlaN Admin - -`govoplan-admin` owns generic system administration API and WebUI contributions -during the GovOPlaN module split. - -This repository owns the live `governance_templates` and -`governance_template_assignments` tables while preserving their historical -table names. 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. - -## WebUI Package - -The repository root and `webui/` directory both expose the package -`@govoplan/admin-webui`. The package does not contribute the `/admin` route -itself; `govoplan-access` owns the route shell. Instead, admin contributes -section entries through core's `admin.sections` UI capability: - -- overview -- system settings -- governance template tenant roles -- governance template groups - -The route shell in access collects those sections at runtime, applies their -scope requirements, and renders them without importing admin package internals. diff --git a/src/govoplan_admin.egg-info/SOURCES.txt b/src/govoplan_admin.egg-info/SOURCES.txt deleted file mode 100644 index 80cf3b6..0000000 --- a/src/govoplan_admin.egg-info/SOURCES.txt +++ /dev/null @@ -1,19 +0,0 @@ -README.md -pyproject.toml -src/govoplan_admin/__init__.py -src/govoplan_admin/py.typed -src/govoplan_admin.egg-info/PKG-INFO -src/govoplan_admin.egg-info/SOURCES.txt -src/govoplan_admin.egg-info/dependency_links.txt -src/govoplan_admin.egg-info/entry_points.txt -src/govoplan_admin.egg-info/requires.txt -src/govoplan_admin.egg-info/top_level.txt -src/govoplan_admin/backend/__init__.py -src/govoplan_admin/backend/governance.py -src/govoplan_admin/backend/manifest.py -src/govoplan_admin/backend/api/__init__.py -src/govoplan_admin/backend/api/v1/__init__.py -src/govoplan_admin/backend/api/v1/routes.py -src/govoplan_admin/backend/api/v1/schemas.py -src/govoplan_admin/backend/db/__init__.py -src/govoplan_admin/backend/db/models.py \ No newline at end of file diff --git a/src/govoplan_admin.egg-info/dependency_links.txt b/src/govoplan_admin.egg-info/dependency_links.txt deleted file mode 100644 index 8b13789..0000000 --- a/src/govoplan_admin.egg-info/dependency_links.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/govoplan_admin.egg-info/entry_points.txt b/src/govoplan_admin.egg-info/entry_points.txt deleted file mode 100644 index 49119b2..0000000 --- a/src/govoplan_admin.egg-info/entry_points.txt +++ /dev/null @@ -1,2 +0,0 @@ -[govoplan.modules] -admin = govoplan_admin.backend.manifest:get_manifest diff --git a/src/govoplan_admin.egg-info/requires.txt b/src/govoplan_admin.egg-info/requires.txt deleted file mode 100644 index 58ec5d6..0000000 --- a/src/govoplan_admin.egg-info/requires.txt +++ /dev/null @@ -1,2 +0,0 @@ -govoplan-core>=0.1.4 -govoplan-access>=0.1.4 diff --git a/src/govoplan_admin.egg-info/top_level.txt b/src/govoplan_admin.egg-info/top_level.txt deleted file mode 100644 index ae9ef12..0000000 --- a/src/govoplan_admin.egg-info/top_level.txt +++ /dev/null @@ -1 +0,0 @@ -govoplan_admin diff --git a/src/govoplan_admin/__pycache__/__init__.cpython-312.pyc b/src/govoplan_admin/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 1c179ca..0000000 Binary files a/src/govoplan_admin/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/src/govoplan_admin/__pycache__/__init__.cpython-313.pyc b/src/govoplan_admin/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 8f9aeb4..0000000 Binary files a/src/govoplan_admin/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/src/govoplan_admin/backend/__pycache__/__init__.cpython-312.pyc b/src/govoplan_admin/backend/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 70ba3c4..0000000 Binary files a/src/govoplan_admin/backend/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/src/govoplan_admin/backend/__pycache__/__init__.cpython-313.pyc b/src/govoplan_admin/backend/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 31f8e84..0000000 Binary files a/src/govoplan_admin/backend/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/src/govoplan_admin/backend/__pycache__/governance.cpython-312.pyc b/src/govoplan_admin/backend/__pycache__/governance.cpython-312.pyc deleted file mode 100644 index 5174cf8..0000000 Binary files a/src/govoplan_admin/backend/__pycache__/governance.cpython-312.pyc and /dev/null differ diff --git a/src/govoplan_admin/backend/__pycache__/governance.cpython-313.pyc b/src/govoplan_admin/backend/__pycache__/governance.cpython-313.pyc deleted file mode 100644 index 6b29a00..0000000 Binary files a/src/govoplan_admin/backend/__pycache__/governance.cpython-313.pyc and /dev/null differ diff --git a/src/govoplan_admin/backend/__pycache__/manifest.cpython-312.pyc b/src/govoplan_admin/backend/__pycache__/manifest.cpython-312.pyc deleted file mode 100644 index fe11c03..0000000 Binary files a/src/govoplan_admin/backend/__pycache__/manifest.cpython-312.pyc and /dev/null differ diff --git a/src/govoplan_admin/backend/__pycache__/manifest.cpython-313.pyc b/src/govoplan_admin/backend/__pycache__/manifest.cpython-313.pyc deleted file mode 100644 index 2ba156d..0000000 Binary files a/src/govoplan_admin/backend/__pycache__/manifest.cpython-313.pyc and /dev/null differ diff --git a/src/govoplan_admin/backend/api/__pycache__/__init__.cpython-312.pyc b/src/govoplan_admin/backend/api/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 0035353..0000000 Binary files a/src/govoplan_admin/backend/api/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/src/govoplan_admin/backend/api/__pycache__/__init__.cpython-313.pyc b/src/govoplan_admin/backend/api/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 8da0473..0000000 Binary files a/src/govoplan_admin/backend/api/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/src/govoplan_admin/backend/api/v1/__pycache__/__init__.cpython-312.pyc b/src/govoplan_admin/backend/api/v1/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 19cfd53..0000000 Binary files a/src/govoplan_admin/backend/api/v1/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/src/govoplan_admin/backend/api/v1/__pycache__/__init__.cpython-313.pyc b/src/govoplan_admin/backend/api/v1/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index ef805b7..0000000 Binary files a/src/govoplan_admin/backend/api/v1/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/src/govoplan_admin/backend/api/v1/__pycache__/routes.cpython-312.pyc b/src/govoplan_admin/backend/api/v1/__pycache__/routes.cpython-312.pyc deleted file mode 100644 index 550bec9..0000000 Binary files a/src/govoplan_admin/backend/api/v1/__pycache__/routes.cpython-312.pyc and /dev/null differ diff --git a/src/govoplan_admin/backend/api/v1/__pycache__/routes.cpython-313.pyc b/src/govoplan_admin/backend/api/v1/__pycache__/routes.cpython-313.pyc deleted file mode 100644 index f1ceb0c..0000000 Binary files a/src/govoplan_admin/backend/api/v1/__pycache__/routes.cpython-313.pyc and /dev/null differ diff --git a/src/govoplan_admin/backend/api/v1/__pycache__/schemas.cpython-312.pyc b/src/govoplan_admin/backend/api/v1/__pycache__/schemas.cpython-312.pyc deleted file mode 100644 index db4e31a..0000000 Binary files a/src/govoplan_admin/backend/api/v1/__pycache__/schemas.cpython-312.pyc and /dev/null differ diff --git a/src/govoplan_admin/backend/api/v1/__pycache__/schemas.cpython-313.pyc b/src/govoplan_admin/backend/api/v1/__pycache__/schemas.cpython-313.pyc deleted file mode 100644 index 32d3f55..0000000 Binary files a/src/govoplan_admin/backend/api/v1/__pycache__/schemas.cpython-313.pyc and /dev/null differ diff --git a/src/govoplan_admin/backend/api/v1/routes.py b/src/govoplan_admin/backend/api/v1/routes.py index 6a07d84..4fdcecb 100644 --- a/src/govoplan_admin/backend/api/v1/routes.py +++ b/src/govoplan_admin/backend/api/v1/routes.py @@ -2,17 +2,33 @@ from __future__ import annotations import os from pathlib import Path +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_core.admin.settings import get_system_settings -from govoplan_access.backend.auth.dependencies import ApiPrincipal, has_scope, require_any_scope, require_scope -from govoplan_core.audit.logging import audit_from_principal +from govoplan_access.auth import ApiPrincipal, has_scope, require_any_scope, require_scope +from govoplan_core.audit.logging import audit_from_principal, audit_operation_context from govoplan_admin.backend.db.models import GovernanceTemplate, GovernanceTemplateAssignment from govoplan_core.admin.common import AdminConflictError, AdminValidationError from govoplan_core.core.access import CAPABILITY_ACCESS_ADMINISTRATION, AccessAdministration +from govoplan_core.core.change_sequence import ( + decode_sequence_watermark, + encode_sequence_watermark, + max_sequence_id, + record_change, + sequence_entries_since, + sequence_watermark_is_expired, +) +from govoplan_core.core.pagination import KeysetCursorError, decode_keyset_cursor, encode_keyset_cursor, keyset_query_fingerprint +from govoplan_core.core.configuration_control import ( + ConfigurationChangeApproval, + ConfigurationControlError, + ensure_configuration_change_allowed, + record_configuration_change_applied, +) from govoplan_core.core.module_management import ( PROTECTED_MODULES, ModuleInstallPlanItem, @@ -41,13 +57,20 @@ from govoplan_core.core.module_installer import ( read_module_installer_request, retry_module_installer_request, ) -from govoplan_core.core.module_license import module_license_decision +from govoplan_core.core.module_license import module_license_decision, module_license_diagnostics from govoplan_core.core.module_package_catalog import record_module_package_catalog_acceptance, validate_module_package_catalog from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, MaintenanceMode, saved_maintenance_mode, save_maintenance_mode from govoplan_core.core.lifecycle import ModuleLifecycleManager from govoplan_core.core.registry import PlatformRegistry from govoplan_core.core.runtime import get_registry from govoplan_core.db.session import get_session +from govoplan_core.i18n import ( + i18n_settings, + normalize_enabled_language_codes, + normalize_language_packages, + system_i18n_payload, + update_i18n_settings, +) from govoplan_core.privacy.retention import privacy_policy_from_settings, set_privacy_policy from govoplan_core.security.permissions import ALL_PERMISSIONS, effective_permission_count from govoplan_core.server.registry import available_module_manifests @@ -59,6 +82,7 @@ from .schemas import ( AdminOverviewResponse, GovernanceTemplateCreateRequest, GovernanceTemplateItem, + GovernanceTemplateListDeltaResponse, GovernanceTemplateListResponse, GovernanceTemplateUpdateRequest, MaintenanceModeItem, @@ -76,14 +100,33 @@ from .schemas import ( ModuleInstallPlanUpdateRequest, ModulePackageCatalogItem, ModulePackageCatalogResponse, + ModuleLicenseDiagnostics, ModuleStateUpdateRequest, PrivacyRetentionPolicyItem, + SystemSettingsDeltaResponse, SystemSettingsItem, SystemSettingsUpdateRequest, ) router = APIRouter(prefix="/admin", tags=["admin"]) +ADMIN_MODULE_ID = "admin" +SYSTEM_SETTINGS_COLLECTION = "admin.system_settings" +SYSTEM_SETTINGS_RESOURCE = "system_settings_section" +GOVERNANCE_TEMPLATES_COLLECTION = "admin.governance_templates" +GOVERNANCE_TEMPLATE_RESOURCE = "governance_template" +INSTALLER_RUNS_CURSOR_SCOPE = "admin.installer.runs.v1" +INSTALLER_REQUESTS_CURSOR_SCOPE = "admin.installer.requests.v1" +DEFAULT_INSTALLER_HISTORY_PAGE_SIZE = 25 +SYSTEM_SETTINGS_SECTIONS = ( + "defaults", + "tenant_capabilities", + "languages", + "privacy_retention_policy", + "maintenance_mode", + "settings", +) + def _access_administration() -> AccessAdministration: registry = get_registry() @@ -113,10 +156,136 @@ def _http_admin_error(exc: Exception) -> HTTPException: if isinstance(exc, AdminConflictError): return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) if isinstance(exc, AdminValidationError): - return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) + return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) +def _configuration_control_http_error(exc: ConfigurationControlError) -> HTTPException: + return HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={"code": exc.code, "message": str(exc), "plan": exc.plan}, + ) + + +def _admin_delta_watermark(session: Session, collections: tuple[str, ...]) -> str: + return encode_sequence_watermark(max_sequence_id(session, module_id=ADMIN_MODULE_ID, collections=collections)) + + +def _admin_delta_entries(session: Session, *, collections: tuple[str, ...], since: str, limit: int): + try: + since_sequence = decode_sequence_watermark(since) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + if sequence_watermark_is_expired( + session, + since=since_sequence, + module_id=ADMIN_MODULE_ID, + collections=collections, + ): + return None, False + entries_plus_one = sequence_entries_since( + session, + since=since_sequence, + module_id=ADMIN_MODULE_ID, + collections=collections, + limit=limit + 1, + ) + has_more = len(entries_plus_one) > limit + return entries_plus_one[:limit], has_more + + +def _admin_delta_response_watermark(session: Session, *, collections: tuple[str, ...], entries, has_more: bool) -> str: + return encode_sequence_watermark(entries[-1].id) if has_more and entries else _admin_delta_watermark(session, collections) + + +def _history_cursor_fingerprint(scope: str, *, page_size: int) -> str: + return keyset_query_fingerprint(scope, {"page_size": page_size, "sort": "id.desc"}) + + +def _window_history_records(records: list[dict[str, object]], *, id_key: str, scope: str, page_size: int, cursor: str | None): + start = 0 + full = False + fingerprint = _history_cursor_fingerprint(scope, page_size=page_size) + if cursor: + try: + values = decode_keyset_cursor(scope, cursor, fingerprint=fingerprint) + except KeysetCursorError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + after_id = values.get(id_key) if values else None + if not isinstance(after_id, str): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid pagination cursor") + matching_index = next((index for index, item in enumerate(records) if str(item.get(id_key) or "") == after_id), None) + if matching_index is None: + full = True + else: + start = matching_index + 1 + page = records[start:start + page_size] + next_cursor = None + if start + page_size < len(records) and page: + next_cursor = encode_keyset_cursor(scope, fingerprint=fingerprint, values={id_key: str(page[-1].get(id_key) or ""), "page_size": page_size}) + return page, next_cursor, full + + +def _system_settings_item(session: Session) -> SystemSettingsItem: + item = get_system_settings(session) + policy = privacy_policy_from_settings(item) + maintenance_mode = saved_maintenance_mode(session) + i18n_payload = system_i18n_payload(item) + return SystemSettingsItem( + default_locale=item.default_locale, + allow_tenant_custom_groups=item.allow_tenant_custom_groups, + allow_tenant_custom_roles=item.allow_tenant_custom_roles, + allow_tenant_api_keys=item.allow_tenant_api_keys, + privacy_retention_policy=PrivacyRetentionPolicyItem.model_validate(policy.model_dump(mode="json")), + maintenance_mode=MaintenanceModeItem.model_validate(maintenance_mode.as_dict()), + available_languages=i18n_payload["available_languages"], + enabled_language_codes=i18n_payload["enabled_languages"], + settings=item.settings or {}, + ) + + +def _system_settings_sections(item: SystemSettingsItem) -> dict[str, Any]: + payload = item.model_dump(mode="json") + return { + "defaults": {"default_locale": payload["default_locale"]}, + "tenant_capabilities": { + "allow_tenant_custom_groups": payload["allow_tenant_custom_groups"], + "allow_tenant_custom_roles": payload["allow_tenant_custom_roles"], + "allow_tenant_api_keys": payload["allow_tenant_api_keys"], + }, + "languages": { + "available_languages": payload["available_languages"], + "enabled_language_codes": payload["enabled_language_codes"], + }, + "privacy_retention_policy": payload["privacy_retention_policy"], + "maintenance_mode": payload["maintenance_mode"], + "settings": payload["settings"], + } + + +def _record_system_settings_section_changes( + session: Session, + *, + before: dict[str, Any], + after: dict[str, Any], + principal: ApiPrincipal, +) -> None: + for section in SYSTEM_SETTINGS_SECTIONS: + if before.get(section) == after.get(section): + continue + record_change( + session, + module_id=ADMIN_MODULE_ID, + collection=SYSTEM_SETTINGS_COLLECTION, + resource_type=SYSTEM_SETTINGS_RESOURCE, + resource_id=section, + operation="updated", + actor_type="user", + actor_id=principal.user.id, + payload={"section": section}, + ) + + def _governance_template_item(session: Session, item: GovernanceTemplate) -> GovernanceTemplateItem: assignments = session.query(GovernanceTemplateAssignment).filter( GovernanceTemplateAssignment.template_id == item.id @@ -136,6 +305,28 @@ def _governance_template_item(session: Session, item: GovernanceTemplate) -> Gov ) +def _record_governance_template_change(session: Session, *, item: GovernanceTemplate, operation: str, principal: ApiPrincipal) -> None: + record_change( + session, + module_id=ADMIN_MODULE_ID, + collection=GOVERNANCE_TEMPLATES_COLLECTION, + resource_type=GOVERNANCE_TEMPLATE_RESOURCE, + resource_id=item.id, + operation=operation, + actor_type="user", + actor_id=principal.user.id, + payload={"kind": item.kind, "slug": item.slug, "name": item.name, "is_active": item.is_active}, + ) + + +def _governance_template_deleted_entries(entries, visible_template_ids: set[str]): + return [ + {"id": entry.resource_id, "resource_type": entry.resource_type or GOVERNANCE_TEMPLATE_RESOURCE} + for entry in entries + if entry.resource_id and (entry.operation == "deleted" or entry.resource_id not in visible_template_ids) + ] + + def _module_catalog_response(session: Session, request: Request, *, notes: list[str] | None = None) -> ModuleCatalogResponse: registry = _request_registry(request) current_enabled = [manifest.id for manifest in registry.manifests()] @@ -251,7 +442,7 @@ def _catalog_plan_item(module_id: str) -> tuple[ModuleInstallPlanItem, dict[str, result = validate_module_package_catalog() if not result.get("valid"): raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(result.get("error") or "Module package catalog is invalid."), ) for raw_item in result.get("modules", []): @@ -302,6 +493,14 @@ def _module_package_catalog_item(raw_item: dict[str, object]) -> ModulePackageCa return ModulePackageCatalogItem.model_validate(payload) +def _catalog_required_license_features(result: dict[str, object]) -> list[str]: + required: list[str] = [] + for raw_item in result.get("modules", []): + if isinstance(raw_item, dict): + required.extend(_catalog_license_features(raw_item)) + return list(dict.fromkeys(required)) + + @router.get("/overview", response_model=AdminOverviewResponse) def admin_overview( session: Session = Depends(get_session), @@ -355,16 +554,23 @@ def read_module_install_plan( @router.get("/system/modules/install-runs", response_model=ModuleInstallerRunListResponse) def list_module_install_runs( + page_size: int = Query(default=DEFAULT_INSTALLER_HISTORY_PAGE_SIZE, ge=1, le=100), + cursor: str | None = None, principal: ApiPrincipal = Depends(require_scope("system:settings:read")), ): del principal runtime_dir = default_installer_runtime_dir(core_settings.database_url) + records = list(list_module_installer_runs(runtime_dir=runtime_dir, limit=1000)) + page, next_cursor, full = _window_history_records(records, id_key="run_id", scope=INSTALLER_RUNS_CURSOR_SCOPE, page_size=page_size, cursor=cursor) return ModuleInstallerRunListResponse( runs=[ ModuleInstallerRunSummary.model_validate(item) - for item in list_module_installer_runs(runtime_dir=runtime_dir) + for item in page ], lock=ModuleInstallerLockStatus.model_validate(module_installer_lock_status(runtime_dir=runtime_dir)), + cursor=cursor, + next_cursor=next_cursor, + full=full, ) @@ -383,16 +589,23 @@ def read_module_install_run_detail( @router.get("/system/modules/install-requests", response_model=ModuleInstallerRequestListResponse) def list_module_install_requests( + page_size: int = Query(default=DEFAULT_INSTALLER_HISTORY_PAGE_SIZE, ge=1, le=100), + cursor: str | None = None, principal: ApiPrincipal = Depends(require_scope("system:settings:read")), ): del principal runtime_dir = default_installer_runtime_dir(core_settings.database_url) + records = list(list_module_installer_requests(runtime_dir=runtime_dir, limit=1000)) + page, next_cursor, full = _window_history_records(records, id_key="request_id", scope=INSTALLER_REQUESTS_CURSOR_SCOPE, page_size=page_size, cursor=cursor) return ModuleInstallerRequestListResponse( requests=[ ModuleInstallerRequestItem.model_validate(item) - for item in list_module_installer_requests(runtime_dir=runtime_dir) + for item in page ], daemon=ModuleInstallerDaemonStatus.model_validate(module_installer_daemon_status(runtime_dir=runtime_dir)), + cursor=cursor, + next_cursor=next_cursor, + full=full, ) @@ -433,7 +646,12 @@ def create_module_install_request( scope="system", object_type="module_install_request", object_id=str(request["request_id"]), - details={"options": payload.options.model_dump(exclude_none=True)}, + details=audit_operation_context( + request_id=request.get("request_id"), + outcome=request.get("status"), + trace=request.get("trace") if isinstance(request.get("trace"), dict) else None, + options=payload.options.model_dump(exclude_none=True), + ), ) session.commit() return ModuleInstallerRequestItem.model_validate(request) @@ -458,7 +676,7 @@ def cancel_module_install_request( cancelled_by=principal.user.id, ) except ModuleInstallerError as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc audit_from_principal( session, principal, @@ -466,7 +684,12 @@ def cancel_module_install_request( scope="system", object_type="module_install_request", object_id=request_id, - details={}, + details=audit_operation_context( + request_id=request_id, + outcome=request.get("status"), + trace=request.get("trace") if isinstance(request.get("trace"), dict) else None, + cancelled_by=request.get("cancelled_by"), + ), ) session.commit() return ModuleInstallerRequestItem.model_validate(request) @@ -491,7 +714,7 @@ def retry_module_install_request( requested_by=principal.user.id, ) except ModuleInstallerError as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc audit_from_principal( session, principal, @@ -499,7 +722,13 @@ def retry_module_install_request( scope="system", object_type="module_install_request", object_id=request_id, - details={"new_request_id": request["request_id"]}, + details=audit_operation_context( + request_id=request_id, + outcome=request.get("status"), + trace=request.get("trace") if isinstance(request.get("trace"), dict) else None, + retry_of=request.get("retry_of"), + new_request_id=request.get("request_id"), + ), ) session.commit() return ModuleInstallerRequestItem.model_validate(request) @@ -511,6 +740,7 @@ def read_module_package_catalog( ): del principal result = validate_module_package_catalog() + license_diagnostics = module_license_diagnostics(required_features=_catalog_required_license_features(result)) return ModulePackageCatalogResponse( modules=[_module_package_catalog_item(item) for item in result["modules"] if isinstance(item, dict)], configured=bool(result["configured"]), @@ -518,13 +748,17 @@ def read_module_package_catalog( path=result["path"] if isinstance(result["path"], str) else None, source=result["source"] if isinstance(result.get("source"), str) else None, source_type=result["source_type"] if isinstance(result.get("source_type"), str) else None, + cache_used=bool(result.get("cache_used")), + cache_path=result["cache_path"] if isinstance(result.get("cache_path"), str) else None, channel=result["channel"] if isinstance(result["channel"], str) else None, sequence=result["sequence"] if isinstance(result.get("sequence"), int) else None, generated_at=result["generated_at"] if isinstance(result.get("generated_at"), str) else None, + not_before=result["not_before"] if isinstance(result.get("not_before"), str) else None, expires_at=result["expires_at"] if isinstance(result.get("expires_at"), str) else None, signed=bool(result["signed"]), trusted=bool(result["trusted"]), key_id=result["key_id"] if isinstance(result["key_id"], str) else None, + license=ModuleLicenseDiagnostics.model_validate(license_diagnostics), warnings=[str(item) for item in result["warnings"]] if isinstance(result["warnings"], list) else [], error=result["error"] if isinstance(result["error"], str) else None, ) @@ -542,7 +776,7 @@ def plan_module_install_from_catalog( plan = _upsert_install_plan_item(session, item) record_module_package_catalog_acceptance(validation) except ModuleManagementError as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc audit_from_principal( session, principal, @@ -550,7 +784,16 @@ def plan_module_install_from_catalog( scope="system", object_type="module_install_plan", object_id=module_id, - details={"items": [item.as_dict() for item in plan.items]}, + details=audit_operation_context( + module_id=module_id, + outcome="planned", + items=[item.as_dict() for item in plan.items], + catalog_validation={ + "valid": validation.get("valid"), + "channel": validation.get("channel"), + "key_id": validation.get("key_id"), + }, + ), ) session.commit() return _module_install_plan_response(session, request, notes=[f"Catalog install entry planned for {module_id}."]) @@ -569,14 +812,14 @@ def plan_module_uninstall( configured_enabled = configured_enabled_modules(core_settings.enabled_modules) desired_enabled = set(saved_desired_enabled_modules(session, configured_enabled)) if module_id in PROTECTED_MODULES: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Protected platform modules cannot be uninstalled.") + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Protected platform modules cannot be uninstalled.") if module_id in current_enabled or module_id in desired_enabled: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Disable the module before planning package uninstall.") try: item = module_uninstall_plan_item(module_id, lifecycle.available_modules) plan = _upsert_install_plan_item(session, item) except ModuleManagementError as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc audit_from_principal( session, principal, @@ -584,7 +827,11 @@ def plan_module_uninstall( scope="system", object_type="module_install_plan", object_id=module_id, - details={"items": [item.as_dict() for item in plan.items]}, + details=audit_operation_context( + module_id=module_id, + outcome="planned", + items=[item.as_dict() for item in plan.items], + ), ) session.commit() return _module_install_plan_response(session, request, notes=[f"Package uninstall planned for {module_id}."]) @@ -599,14 +846,28 @@ def update_system_modules( ): lifecycle = _request_lifecycle(request) available = lifecycle.available_modules + before_value = {"enabled_modules": list(saved_desired_enabled_modules(session, configured_enabled_modules(core_settings.enabled_modules)))} + apply_value = {"enabled_modules": list(payload.enabled_modules)} + try: + approval = ensure_configuration_change_allowed( + session, + key="module_management.desired_enabled", + value=apply_value, + actor_user_id=principal.user.id, + actor_scopes=tuple(principal.scopes), + change_request_id=payload.change_request_id, + target={"scope": "system"}, + ) + except ConfigurationControlError as exc: + raise _configuration_control_http_error(exc) from exc try: plan = plan_desired_enabled_modules(payload.enabled_modules, available) except ModuleManagementError as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc try: result = lifecycle.apply_enabled_modules(plan.enabled_modules, protected_modules=PROTECTED_MODULES) except Exception as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc desired = save_desired_enabled_modules(session, result.enabled_modules) audit_from_principal( session, @@ -615,13 +876,24 @@ def update_system_modules( scope="system", object_type="module_state", object_id="global", - details={ - "desired_enabled": list(desired), - "added_dependencies": list(plan.added_dependencies), - "activated": list(result.activated_modules), - "deactivated": list(result.deactivated_modules), - "mounted": list(result.mounted_modules), - }, + details=audit_operation_context( + outcome="applied", + desired_enabled=list(desired), + added_dependencies=list(plan.added_dependencies), + activated=list(result.activated_modules), + deactivated=list(result.deactivated_modules), + mounted=list(result.mounted_modules), + ), + ) + record_configuration_change_applied( + session, + key="module_management.desired_enabled", + before_value=before_value, + after_value={"enabled_modules": list(desired)}, + actor_user_id=principal.user.id, + approval=approval, + target={"scope": "system"}, + audit_event="module_management.updated", ) session.commit() notes = ["Module state saved and applied to the running server."] @@ -641,10 +913,24 @@ def update_module_install_plan( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("system:settings:write")), ): + before_value = {"items": [item.as_dict() for item in saved_module_install_plan(session).items]} + apply_value = {"items": [item.model_dump() for item in payload.items]} + try: + approval = ensure_configuration_change_allowed( + session, + key="module_management.install_plan", + value=apply_value, + actor_user_id=principal.user.id, + actor_scopes=tuple(principal.scopes), + change_request_id=payload.change_request_id, + target={"scope": "system"}, + ) + except ConfigurationControlError as exc: + raise _configuration_control_http_error(exc) from exc try: plan = save_module_install_plan(session, [item.model_dump() for item in payload.items]) except ModuleManagementError as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc audit_from_principal( session, principal, @@ -652,7 +938,21 @@ def update_module_install_plan( scope="system", object_type="module_install_plan", object_id="global", - details={"items": [item.as_dict() for item in plan.items]}, + details=audit_operation_context( + outcome="saved", + items=[item.as_dict() for item in plan.items], + item_count=len(plan.items), + ), + ) + record_configuration_change_applied( + session, + key="module_management.install_plan", + before_value=before_value, + after_value={"items": [item.as_dict() for item in plan.items]}, + actor_user_id=principal.user.id, + approval=approval, + target={"scope": "system"}, + audit_event="module_install.requested", ) session.commit() return _module_install_plan_response(session, request, notes=["Module package install plan saved."]) @@ -672,7 +972,11 @@ def clear_module_install_plan( scope="system", object_type="module_install_plan", object_id="global", - details={"items": [item.as_dict() for item in plan.items]}, + details=audit_operation_context( + outcome="cleared", + items=[item.as_dict() for item in plan.items], + item_count=len(plan.items), + ), ) session.commit() return _module_install_plan_response(session, request, notes=["Module package install plan cleared."]) @@ -683,18 +987,59 @@ def read_system_settings( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("system:settings:read")), ): - item = get_system_settings(session) + get_system_settings(session) session.commit() - policy = privacy_policy_from_settings(item) - maintenance_mode = saved_maintenance_mode(session) - return SystemSettingsItem( - default_locale=item.default_locale, - allow_tenant_custom_groups=item.allow_tenant_custom_groups, - allow_tenant_custom_roles=item.allow_tenant_custom_roles, - allow_tenant_api_keys=item.allow_tenant_api_keys, - privacy_retention_policy=PrivacyRetentionPolicyItem.model_validate(policy.model_dump(mode="json")), - maintenance_mode=MaintenanceModeItem.model_validate(maintenance_mode.as_dict()), - settings=item.settings or {}, + return _system_settings_item(session) + + +def _full_system_settings_delta_response(session: Session) -> SystemSettingsDeltaResponse: + item = _system_settings_item(session) + return SystemSettingsDeltaResponse( + item=item, + sections=_system_settings_sections(item), + changed_sections=list(SYSTEM_SETTINGS_SECTIONS), + deleted=[], + watermark=_admin_delta_watermark(session, (SYSTEM_SETTINGS_COLLECTION,)), + has_more=False, + full=True, + ) + + +@router.get("/system/settings/delta", response_model=SystemSettingsDeltaResponse) +def read_system_settings_delta( + since: str | None = None, + limit: int = Query(default=100, ge=1, le=500), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:settings:read")), +): + del principal + get_system_settings(session) + session.commit() + if since is None: + return _full_system_settings_delta_response(session) + entries, has_more = _admin_delta_entries(session, collections=(SYSTEM_SETTINGS_COLLECTION,), since=since, limit=limit) + if entries is None: + return _full_system_settings_delta_response(session) + changed_sections = [ + section + for section in SYSTEM_SETTINGS_SECTIONS + if section in {entry.resource_id for entry in entries if entry.resource_type == SYSTEM_SETTINGS_RESOURCE} + ] + item = _system_settings_item(session) + sections = _system_settings_sections(item) + return SystemSettingsDeltaResponse( + item=None, + sections={section: sections[section] for section in changed_sections}, + changed_sections=changed_sections, + deleted=[], + watermark=_admin_delta_response_watermark( + session, + collections=(SYSTEM_SETTINGS_COLLECTION,), + entries=entries, + has_more=has_more, + ), + has_more=has_more, + full=False, ) @@ -705,10 +1050,45 @@ def write_system_settings( principal: ApiPrincipal = Depends(require_scope("system:settings:write")), ): item = get_system_settings(session) + privacy_approval: ConfigurationChangeApproval | None = None + maintenance_approval: ConfigurationChangeApproval | None = None + before_sections = _system_settings_sections(_system_settings_item(session)) + before_privacy = privacy_policy_from_settings(item).model_dump(mode="json") + before_maintenance = saved_maintenance_mode(session).as_dict() + if payload.privacy_retention_policy is not None: + privacy_value = payload.privacy_retention_policy.model_dump(mode="json") + try: + privacy_approval = ensure_configuration_change_allowed( + session, + key="privacy_retention_policy", + value=privacy_value, + actor_user_id=principal.user.id, + actor_scopes=tuple(principal.scopes), + change_request_id=payload.change_request_id, + target={"scope": "system"}, + ) + except ConfigurationControlError as exc: + raise _configuration_control_http_error(exc) from exc + current_i18n = i18n_settings(item.settings) + raw_available = payload.available_languages if "available_languages" in payload.model_fields_set else current_i18n.get("available_languages") + raw_enabled = payload.enabled_language_codes if "enabled_language_codes" in payload.model_fields_set else current_i18n.get("enabled_language_codes", current_i18n.get("enabled_languages")) + available_languages = normalize_language_packages( + [item.model_dump() for item in raw_available] if payload.available_languages is not None else raw_available + ) + enabled_language_codes = normalize_enabled_language_codes( + raw_enabled, + available_languages, + default_locale=payload.default_locale, + ) item.default_locale = payload.default_locale.strip() or "en" item.allow_tenant_custom_groups = payload.allow_tenant_custom_groups item.allow_tenant_custom_roles = payload.allow_tenant_custom_roles item.allow_tenant_api_keys = payload.allow_tenant_api_keys + item.settings = update_i18n_settings( + item.settings, + available_languages=available_languages, + enabled_language_codes=enabled_language_codes, + ) if payload.privacy_retention_policy is not None: set_privacy_policy(item, payload.privacy_retention_policy.model_dump(mode="json")) if payload.maintenance_mode is not None: @@ -717,9 +1097,21 @@ def write_system_settings( enabled=payload.maintenance_mode.enabled, message=payload.maintenance_mode.message.strip() if payload.maintenance_mode.message else None, ) + try: + maintenance_approval = ensure_configuration_change_allowed( + session, + key="maintenance_mode", + value=next_mode.as_dict(), + actor_user_id=principal.user.id, + actor_scopes=tuple(principal.scopes), + change_request_id=None, + target={"scope": "system"}, + ) + except ConfigurationControlError as exc: + raise _configuration_control_http_error(exc) from exc if current.enabled != next_mode.enabled and not has_scope(principal, MAINTENANCE_ACCESS_SCOPE): raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=f"Changing maintenance mode requires {MAINTENANCE_ACCESS_SCOPE}.", ) save_maintenance_mode(session, next_mode) @@ -728,8 +1120,32 @@ def write_system_settings( session, principal, action="system_settings.updated", scope="system", object_type="system_settings", object_id=item.id, details=payload.model_dump(), ) + if payload.privacy_retention_policy is not None: + record_configuration_change_applied( + session, + key="privacy_retention_policy", + before_value=before_privacy, + after_value=privacy_policy_from_settings(item).model_dump(mode="json"), + actor_user_id=principal.user.id, + approval=privacy_approval, + target={"scope": "system"}, + audit_event="privacy_retention.policy_updated", + ) + if payload.maintenance_mode is not None: + record_configuration_change_applied( + session, + key="maintenance_mode", + before_value=before_maintenance, + after_value=saved_maintenance_mode(session).as_dict(), + actor_user_id=principal.user.id, + approval=maintenance_approval, + target={"scope": "system"}, + audit_event="system_settings.updated", + ) + after_sections = _system_settings_sections(_system_settings_item(session)) + _record_system_settings_section_changes(session, before=before_sections, after=after_sections, principal=principal) session.commit() - return read_system_settings(session=session, principal=principal) + return _system_settings_item(session) @router.get("/system/governance-templates", response_model=GovernanceTemplateListResponse) @@ -746,12 +1162,63 @@ def list_governance_templates( return GovernanceTemplateListResponse(templates=[_governance_template_item(session, item) for item in items]) +def _full_governance_template_delta_response(session: Session) -> GovernanceTemplateListDeltaResponse: + items = session.query(GovernanceTemplate).order_by(GovernanceTemplate.kind.asc(), GovernanceTemplate.name.asc()).all() + return GovernanceTemplateListDeltaResponse( + templates=[_governance_template_item(session, item) for item in items], + deleted=[], + watermark=_admin_delta_watermark(session, (GOVERNANCE_TEMPLATES_COLLECTION,)), + has_more=False, + full=True, + ) + + +@router.get("/system/governance-templates/delta", response_model=GovernanceTemplateListDeltaResponse) +def list_governance_templates_delta( + since: str | None = None, + limit: int = Query(default=100, ge=1, le=500), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:governance:read")), +): + del principal + if since is None: + return _full_governance_template_delta_response(session) + entries, has_more = _admin_delta_entries(session, collections=(GOVERNANCE_TEMPLATES_COLLECTION,), since=since, limit=limit) + if entries is None: + return _full_governance_template_delta_response(session) + changed_ids = [entry.resource_id for entry in entries if entry.resource_id and entry.operation != "deleted"] + items = [] + if changed_ids: + items = session.query(GovernanceTemplate).filter(GovernanceTemplate.id.in_(changed_ids)).order_by(GovernanceTemplate.kind.asc(), GovernanceTemplate.name.asc()).all() + visible_template_ids = {item.id for item in items} + return GovernanceTemplateListDeltaResponse( + templates=[_governance_template_item(session, item) for item in items], + deleted=_governance_template_deleted_entries(entries, visible_template_ids), + watermark=_admin_delta_response_watermark(session, collections=(GOVERNANCE_TEMPLATES_COLLECTION,), entries=entries, has_more=has_more), + has_more=has_more, + full=False, + ) + + @router.post("/system/governance-templates", response_model=GovernanceTemplateItem, status_code=status.HTTP_201_CREATED) def create_governance_template( payload: GovernanceTemplateCreateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("system:governance:write")), ): + apply_value = payload.model_dump(exclude={"change_request_id"}) + try: + approval = ensure_configuration_change_allowed( + session, + key="governance_templates", + value=apply_value, + actor_user_id=principal.user.id, + actor_scopes=tuple(principal.scopes), + change_request_id=payload.change_request_id, + target={"kind": payload.kind, "slug": payload.slug}, + ) + except ConfigurationControlError as exc: + raise _configuration_control_http_error(exc) from exc try: item = create_template( session, @@ -774,6 +1241,17 @@ def create_governance_template( object_id=item.id, details={"slug": item.slug, "tenant_ids": [assignment.tenant_id for assignment in payload.assignments]}, ) + record_configuration_change_applied( + session, + key="governance_templates", + before_value=None, + after_value={**apply_value, "id": item.id}, + actor_user_id=principal.user.id, + approval=approval, + target={"kind": item.kind, "id": item.id}, + audit_event="governance_template.updated", + ) + _record_governance_template_change(session, item=item, operation="created", principal=principal) session.commit() return _governance_template_item(session, item) @@ -788,6 +1266,20 @@ def update_governance_template( item = session.get(GovernanceTemplate, template_id) if item is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Governance template not found") + before_value = _governance_template_item(session, item).model_dump(mode="json") + apply_value = payload.model_dump(exclude={"change_request_id"}) + try: + approval = ensure_configuration_change_allowed( + session, + key="governance_templates", + value=apply_value, + actor_user_id=principal.user.id, + actor_scopes=tuple(principal.scopes), + change_request_id=payload.change_request_id, + target={"kind": item.kind, "id": item.id}, + ) + except ConfigurationControlError as exc: + raise _configuration_control_http_error(exc) from exc try: update_template( session, @@ -809,6 +1301,17 @@ def update_governance_template( object_id=item.id, details={"tenant_ids": [assignment.tenant_id for assignment in payload.assignments]}, ) + record_configuration_change_applied( + session, + key="governance_templates", + before_value=before_value, + after_value=_governance_template_item(session, item).model_dump(mode="json"), + actor_user_id=principal.user.id, + approval=approval, + target={"kind": item.kind, "id": item.id}, + audit_event="governance_template.updated", + ) + _record_governance_template_change(session, item=item, operation="updated", principal=principal) session.commit() return _governance_template_item(session, item) @@ -816,6 +1319,7 @@ def update_governance_template( @router.delete("/system/governance-templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT) def remove_governance_template( template_id: str, + change_request_id: str | None = Query(default=None), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("system:governance:write")), ): @@ -823,6 +1327,20 @@ def remove_governance_template( if item is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Governance template not found") kind = item.kind + before_value = _governance_template_item(session, item).model_dump(mode="json") + apply_value = {"id": item.id, "kind": item.kind, "delete": True} + try: + approval = ensure_configuration_change_allowed( + session, + key="governance_templates", + value=apply_value, + actor_user_id=principal.user.id, + actor_scopes=tuple(principal.scopes), + change_request_id=change_request_id, + target={"kind": item.kind, "id": item.id}, + ) + except ConfigurationControlError as exc: + raise _configuration_control_http_error(exc) from exc try: delete_template(session, item) except (AdminConflictError, AdminValidationError) as exc: @@ -836,5 +1354,16 @@ def remove_governance_template( object_id=template_id, details={}, ) + record_configuration_change_applied( + session, + key="governance_templates", + before_value=before_value, + after_value=None, + actor_user_id=principal.user.id, + approval=approval, + target={"kind": kind, "id": template_id}, + audit_event="governance_template.updated", + ) + _record_governance_template_change(session, item=item, operation="deleted", principal=principal) session.commit() return None diff --git a/src/govoplan_admin/backend/api/v1/schemas.py b/src/govoplan_admin/backend/api/v1/schemas.py index 69abf62..75cbaa6 100644 --- a/src/govoplan_admin/backend/api/v1/schemas.py +++ b/src/govoplan_admin/backend/api/v1/schemas.py @@ -5,6 +5,8 @@ from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, field_validator +from govoplan_core.api.v1.schemas import DeltaDeletedItem + RETENTION_DAY_KEYS = ( "raw_campaign_json_retention_days", @@ -65,6 +67,14 @@ class MaintenanceModeItem(BaseModel): message: str | None = Field(default=None, max_length=500) +class LanguagePackageItem(BaseModel): + model_config = ConfigDict(extra="forbid") + + code: str = Field(min_length=1, max_length=20) + label: str = Field(min_length=1, max_length=100) + native_label: str | None = Field(default=None, max_length=100) + + class AdminOverviewResponse(BaseModel): active_tenant_id: str active_tenant_name: str @@ -87,9 +97,21 @@ class SystemSettingsItem(BaseModel): allow_tenant_api_keys: bool = True privacy_retention_policy: PrivacyRetentionPolicyItem = Field(default_factory=PrivacyRetentionPolicyItem) maintenance_mode: MaintenanceModeItem = Field(default_factory=MaintenanceModeItem) + available_languages: list[LanguagePackageItem] = Field(default_factory=list) + enabled_language_codes: list[str] = Field(default_factory=list) settings: dict[str, Any] = Field(default_factory=dict) +class SystemSettingsDeltaResponse(BaseModel): + item: SystemSettingsItem | None = None + sections: dict[str, Any] = Field(default_factory=dict) + changed_sections: list[str] = Field(default_factory=list) + deleted: list[DeltaDeletedItem] = Field(default_factory=list) + watermark: str | None = None + has_more: bool = False + full: bool = False + + class SystemSettingsUpdateRequest(BaseModel): model_config = ConfigDict(extra="forbid") @@ -99,6 +121,9 @@ class SystemSettingsUpdateRequest(BaseModel): allow_tenant_api_keys: bool privacy_retention_policy: PrivacyRetentionPolicyItem | None = None maintenance_mode: MaintenanceModeItem | None = None + available_languages: list[LanguagePackageItem] | None = None + enabled_language_codes: list[str] | None = None + change_request_id: str | None = None class ModuleCatalogItem(BaseModel): @@ -143,6 +168,7 @@ class ModuleStateUpdateRequest(BaseModel): model_config = ConfigDict(extra="forbid") enabled_modules: list[str] = Field(default_factory=list) + change_request_id: str | None = None class ModuleInstallPlanItem(BaseModel): @@ -213,6 +239,9 @@ class ModuleInstallerRunSummary(BaseModel): status: str started_at: str | None = None finished_at: str | None = None + request_id: str | None = None + requested_by: str | None = None + trace: dict[str, Any] | None = None rollback_status: str | None = None supervisor_status: str | None = None error: str | None = None @@ -224,6 +253,9 @@ class ModuleInstallerRunSummary(BaseModel): class ModuleInstallerRunListResponse(BaseModel): runs: list[ModuleInstallerRunSummary] = Field(default_factory=list) lock: ModuleInstallerLockStatus + cursor: str | None = None + next_cursor: str | None = None + full: bool = False class ModuleInstallerRequestOptions(BaseModel): @@ -256,6 +288,7 @@ class ModuleInstallerRequestItem(BaseModel): cancelled_at: str | None = None cancelled_by: str | None = None retry_of: str | None = None + trace: dict[str, Any] | None = None error: str | None = None record_path: str | None = None options: dict[str, Any] = Field(default_factory=dict) @@ -264,6 +297,9 @@ class ModuleInstallerRequestItem(BaseModel): class ModuleInstallerRequestListResponse(BaseModel): requests: list[ModuleInstallerRequestItem] = Field(default_factory=list) daemon: ModuleInstallerDaemonStatus + cursor: str | None = None + next_cursor: str | None = None + full: bool = False class ModuleInstallerRequestCreateRequest(BaseModel): @@ -291,6 +327,27 @@ class ModulePackageCatalogItem(BaseModel): tags: list[str] = Field(default_factory=list) +class ModuleLicenseDiagnostics(BaseModel): + configured: bool = False + valid: bool = True + path: str | None = None + license_id: str | None = None + subject: str | None = None + features: list[str] = Field(default_factory=list) + valid_from: str | None = None + valid_until: str | None = None + signed: bool = False + trusted: bool = False + key_id: str | None = None + enforced: bool = False + allowed: bool = True + required_features: list[str] = Field(default_factory=list) + missing_features: list[str] = Field(default_factory=list) + expires_in_days: int | None = None + reason: str | None = None + guidance: list[str] = Field(default_factory=list) + + class ModulePackageCatalogResponse(BaseModel): modules: list[ModulePackageCatalogItem] = Field(default_factory=list) configured: bool = False @@ -298,13 +355,17 @@ class ModulePackageCatalogResponse(BaseModel): path: str | None = None source: str | None = None source_type: str | None = None + cache_used: bool = False + cache_path: str | None = None channel: str | None = None sequence: int | None = None generated_at: str | None = None + not_before: str | None = None expires_at: str | None = None signed: bool = False trusted: bool = False key_id: str | None = None + license: ModuleLicenseDiagnostics = Field(default_factory=ModuleLicenseDiagnostics) warnings: list[str] = Field(default_factory=list) error: str | None = None @@ -313,6 +374,7 @@ class ModuleInstallPlanUpdateRequest(BaseModel): model_config = ConfigDict(extra="forbid") items: list[ModuleInstallPlanItem] = Field(default_factory=list) + change_request_id: str | None = None class GovernanceAssignment(BaseModel): @@ -338,6 +400,14 @@ class GovernanceTemplateListResponse(BaseModel): templates: list[GovernanceTemplateItem] +class GovernanceTemplateListDeltaResponse(BaseModel): + templates: list[GovernanceTemplateItem] = Field(default_factory=list) + deleted: list[DeltaDeletedItem] = Field(default_factory=list) + watermark: str | None = None + has_more: bool = False + full: bool = False + + class GovernanceTemplateCreateRequest(BaseModel): model_config = ConfigDict(extra="forbid") @@ -348,6 +418,7 @@ class GovernanceTemplateCreateRequest(BaseModel): permissions: list[str] = Field(default_factory=list) is_active: bool = True assignments: list[GovernanceAssignment] = Field(default_factory=list) + change_request_id: str | None = None class GovernanceTemplateUpdateRequest(BaseModel): @@ -358,3 +429,4 @@ class GovernanceTemplateUpdateRequest(BaseModel): permissions: list[str] = Field(default_factory=list) is_active: bool = True assignments: list[GovernanceAssignment] = Field(default_factory=list) + change_request_id: str | None = None diff --git a/src/govoplan_admin/backend/db/__pycache__/__init__.cpython-312.pyc b/src/govoplan_admin/backend/db/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 5a119ab..0000000 Binary files a/src/govoplan_admin/backend/db/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/src/govoplan_admin/backend/db/__pycache__/__init__.cpython-313.pyc b/src/govoplan_admin/backend/db/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index dff24f4..0000000 Binary files a/src/govoplan_admin/backend/db/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/src/govoplan_admin/backend/db/__pycache__/models.cpython-312.pyc b/src/govoplan_admin/backend/db/__pycache__/models.cpython-312.pyc deleted file mode 100644 index c7b632f..0000000 Binary files a/src/govoplan_admin/backend/db/__pycache__/models.cpython-312.pyc and /dev/null differ diff --git a/src/govoplan_admin/backend/db/__pycache__/models.cpython-313.pyc b/src/govoplan_admin/backend/db/__pycache__/models.cpython-313.pyc deleted file mode 100644 index 613e4bb..0000000 Binary files a/src/govoplan_admin/backend/db/__pycache__/models.cpython-313.pyc and /dev/null differ diff --git a/src/govoplan_admin/backend/db/models.py b/src/govoplan_admin/backend/db/models.py index 0b2f941..9f89675 100644 --- a/src/govoplan_admin/backend/db/models.py +++ b/src/govoplan_admin/backend/db/models.py @@ -13,7 +13,7 @@ def new_uuid() -> str: class GovernanceTemplate(Base, TimestampMixin): - __tablename__ = "governance_templates" + __tablename__ = "admin_governance_templates" __table_args__ = (UniqueConstraint("kind", "slug", name="uq_governance_templates_kind_slug"),) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) @@ -26,12 +26,12 @@ class GovernanceTemplate(Base, TimestampMixin): class GovernanceTemplateAssignment(Base, TimestampMixin): - __tablename__ = "governance_template_assignments" + __tablename__ = "admin_governance_template_assignments" __table_args__ = (UniqueConstraint("template_id", "tenant_id", name="uq_governance_template_tenant"),) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) - template_id: Mapped[str] = mapped_column(ForeignKey("governance_templates.id", ondelete="CASCADE"), nullable=False, index=True) - tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + template_id: Mapped[str] = mapped_column(ForeignKey("admin_governance_templates.id", ondelete="CASCADE"), nullable=False, index=True) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True) mode: Mapped[str] = mapped_column(String(20), default="available", nullable=False) diff --git a/webui/package.json b/webui/package.json index 44ee4cb..c5bc812 100644 --- a/webui/package.json +++ b/webui/package.json @@ -14,7 +14,7 @@ }, "peerDependencies": { "@govoplan/core-webui": "^0.1.6", - "lucide-react": "^0.555.0", + "lucide-react": "^1.23.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.1.1" diff --git a/webui/src/api/admin.ts b/webui/src/api/admin.ts index e1b2dfa..61e6f96 100644 --- a/webui/src/api/admin.ts +++ b/webui/src/api/admin.ts @@ -1,4 +1,4 @@ -import type { ApiSettings } from "@govoplan/core-webui"; +import type { ApiSettings, DeltaDeletedItem } from "@govoplan/core-webui"; import { apiFetch } from "@govoplan/core-webui"; export type PermissionItem = { @@ -75,6 +75,12 @@ export type MaintenanceMode = { message?: string | null; }; +export type LanguagePackage = { + code: string; + label: string; + native_label?: string | null; +}; + export type SystemSettingsItem = { default_locale: string; allow_tenant_custom_groups: boolean; @@ -82,9 +88,20 @@ export type SystemSettingsItem = { allow_tenant_api_keys: boolean; privacy_retention_policy: PrivacyRetentionPolicy; maintenance_mode: MaintenanceMode; + available_languages: LanguagePackage[]; + enabled_language_codes: string[]; settings: Record; }; +export type SystemSettingsDeltaSections = Partial<{ + defaults: Pick; + tenant_capabilities: Pick; + languages: Pick; + privacy_retention_policy: SystemSettingsItem["privacy_retention_policy"]; + maintenance_mode: SystemSettingsItem["maintenance_mode"]; + settings: SystemSettingsItem["settings"]; +}>; + export type SystemSettingsUpdatePayload = { default_locale: string; allow_tenant_custom_groups: boolean; @@ -92,8 +109,106 @@ export type SystemSettingsUpdatePayload = { allow_tenant_api_keys: boolean; privacy_retention_policy?: PrivacyRetentionPolicy | null; maintenance_mode?: MaintenanceMode | null; + available_languages?: LanguagePackage[] | null; + enabled_language_codes?: string[] | null; + change_request_id?: string | null; }; +export type ConfigurationChangeRequest = { + id: string; + key: string; + label?: string; + target?: Record; + dry_run: boolean; + requested_by: string; + requested_at: string; + updated_at: string; + status: string; + approvals: Array>; + plan: Record; + value_preview?: unknown; +}; + +export type ConfigurationChangeRecord = { + id: string; + version: number; + key: string; + target?: Record; + actor_user_id: string; + approval_request_id?: string | null; + approval_user_ids: string[]; + before?: unknown; + after?: unknown; + rollback_value?: unknown; + status: string; + created_at: string; +}; + +export type ConfigurationPackageDiagnostic = { + severity: "blocker" | "warning" | "info"; + code: string; + message: string; + module_id?: string | null; + object_ref?: string | null; + resolution?: string | null; +}; + +export type ConfigurationPackageRequiredData = { + key: string; + label: string; + data_type: string; + required: boolean; + secret: boolean; + description?: string | null; +}; + +export type ConfigurationPackagePlanItem = { + action: "create" | "update" | "bind" | "skip" | "blocked" | "noop"; + module_id: string; + fragment_type: string; + fragment_id?: string | null; + summary?: string | null; +}; + +export type ConfigurationPackageFragment = { + module_id: string; + fragment_type: string; + fragment_id?: string | null; + payload: Record; +}; + +export type ConfigurationPackageRunPayload = { + package: Record; + tenant_id?: string | null; + supplied_data?: Record; + change_request_id?: string | null; +}; + +type DeltaResponseFields = { + deleted: DeltaDeletedItem[]; + watermark?: string | null; + has_more: boolean; + full: boolean; +}; + +function deltaSuffix(options: { since?: string | null; limit?: number } = {}): string { + const params = new URLSearchParams(); + if (options.since) params.set("since", options.since); + if (options.limit) params.set("limit", String(options.limit)); + return params.toString() ? `?${params.toString()}` : ""; +} + +export type SystemSettingsDeltaResponse = { + item?: SystemSettingsItem | null; + sections: SystemSettingsDeltaSections; + changed_sections: string[]; +} & DeltaResponseFields; + +export type ConfigurationChangesDeltaResponse = { + requests: ConfigurationChangeRequest[]; + history: ConfigurationChangeRecord[]; +} & DeltaResponseFields; + export type ModuleCatalogItem = { id: string; name: string; @@ -196,6 +311,9 @@ export type ModuleInstallerRunSummary = { status: string; started_at?: string | null; finished_at?: string | null; + request_id?: string | null; + requested_by?: string | null; + trace?: Record | null; rollback_status?: string | null; supervisor_status?: string | null; error?: string | null; @@ -207,6 +325,9 @@ export type ModuleInstallerRunSummary = { export type ModuleInstallerRunListResponse = { runs: ModuleInstallerRunSummary[]; lock: ModuleInstallerLockStatus; + cursor?: string | null; + next_cursor?: string | null; + full?: boolean; }; export type ModuleInstallerRequestOptions = { @@ -235,6 +356,7 @@ export type ModuleInstallerRequestItem = { cancelled_at?: string | null; cancelled_by?: string | null; retry_of?: string | null; + trace?: Record | null; error?: string | null; record_path?: string | null; options: Record; @@ -243,6 +365,9 @@ export type ModuleInstallerRequestItem = { export type ModuleInstallerRequestListResponse = { requests: ModuleInstallerRequestItem[]; daemon: ModuleInstallerDaemonStatus; + cursor?: string | null; + next_cursor?: string | null; + full?: boolean; }; export type ModulePackageCatalogItem = { @@ -264,6 +389,27 @@ export type ModulePackageCatalogItem = { tags: string[]; }; +export type ModuleLicenseDiagnostics = { + configured: boolean; + valid: boolean; + path?: string | null; + license_id?: string | null; + subject?: string | null; + features: string[]; + valid_from?: string | null; + valid_until?: string | null; + signed: boolean; + trusted: boolean; + key_id?: string | null; + enforced: boolean; + allowed: boolean; + required_features: string[]; + missing_features: string[]; + expires_in_days?: number | null; + reason?: string | null; + guidance: string[]; +}; + export type ModulePackageCatalogResponse = { modules: ModulePackageCatalogItem[]; configured: boolean; @@ -271,13 +417,17 @@ export type ModulePackageCatalogResponse = { path?: string | null; source?: string | null; source_type?: string | null; + cache_used: boolean; + cache_path?: string | null; channel?: string | null; sequence?: number | null; generated_at?: string | null; + not_before?: string | null; expires_at?: string | null; signed: boolean; trusted: boolean; key_id?: string | null; + license: ModuleLicenseDiagnostics; warnings: string[]; error?: string | null; }; @@ -319,6 +469,11 @@ export function fetchSystemSettings(settings: ApiSettings): Promise { + const suffix = deltaSuffix(options); + return apiFetch(settings, `/api/v1/admin/system/settings/delta${suffix}`); +} + export function updateSystemSettings(settings: ApiSettings, payload: SystemSettingsUpdatePayload): Promise { return apiFetch(settings, "/api/v1/admin/system/settings", { method: "PATCH", body: JSON.stringify(payload) }); } @@ -327,10 +482,10 @@ export function fetchModuleCatalog(settings: ApiSettings): Promise { +export function updateModuleState(settings: ApiSettings, enabledModules: string[], changeRequestId?: string | null): Promise { return apiFetch(settings, "/api/v1/admin/system/modules", { method: "PUT", - body: JSON.stringify({ enabled_modules: enabledModules }) + body: JSON.stringify({ enabled_modules: enabledModules, change_request_id: changeRequestId ?? null }) }); } @@ -338,12 +493,20 @@ export function fetchModuleInstallPlan(settings: ApiSettings): Promise { - return apiFetch(settings, "/api/v1/admin/system/modules/install-runs"); +export function fetchModuleInstallerRuns(settings: ApiSettings, options: { page_size?: number; cursor?: string | null } = {}): Promise { + const params = new URLSearchParams(); + if (options.page_size) params.set("page_size", String(options.page_size)); + if (options.cursor) params.set("cursor", options.cursor); + const suffix = params.toString() ? `?${params.toString()}` : ""; + return apiFetch(settings, `/api/v1/admin/system/modules/install-runs${suffix}`); } -export function fetchModuleInstallerRequests(settings: ApiSettings): Promise { - return apiFetch(settings, "/api/v1/admin/system/modules/install-requests"); +export function fetchModuleInstallerRequests(settings: ApiSettings, options: { page_size?: number; cursor?: string | null } = {}): Promise { + const params = new URLSearchParams(); + if (options.page_size) params.set("page_size", String(options.page_size)); + if (options.cursor) params.set("cursor", options.cursor); + const suffix = params.toString() ? `?${params.toString()}` : ""; + return apiFetch(settings, `/api/v1/admin/system/modules/install-requests${suffix}`); } export function createModuleInstallerRequest(settings: ApiSettings, options: ModuleInstallerRequestOptions): Promise { @@ -373,10 +536,10 @@ export function planModuleUninstall(settings: ApiSettings, moduleId: string): Pr return apiFetch(settings, `/api/v1/admin/system/modules/${encodeURIComponent(moduleId)}/uninstall-plan`, { method: "POST" }); } -export function updateModuleInstallPlan(settings: ApiSettings, items: ModuleInstallPlanItem[]): Promise { +export function updateModuleInstallPlan(settings: ApiSettings, items: ModuleInstallPlanItem[], changeRequestId?: string | null): Promise { return apiFetch(settings, "/api/v1/admin/system/modules/install-plan", { method: "PUT", - body: JSON.stringify({ items }) + body: JSON.stringify({ items, change_request_id: changeRequestId ?? null }) }); } @@ -390,14 +553,88 @@ export async function fetchGovernanceTemplates(settings: ApiSettings, kind?: "gr return response.templates; } -export function createGovernanceTemplate(settings: ApiSettings, payload: Omit): Promise { +export function createGovernanceTemplate(settings: ApiSettings, payload: Omit & { change_request_id?: string | null }): Promise { return apiFetch(settings, "/api/v1/admin/system/governance-templates", { method: "POST", body: JSON.stringify(payload) }); } -export function updateGovernanceTemplate(settings: ApiSettings, templateId: string, payload: Omit): Promise { +export function updateGovernanceTemplate(settings: ApiSettings, templateId: string, payload: Omit & { change_request_id?: string | null }): Promise { return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}`, { method: "PATCH", body: JSON.stringify(payload) }); } -export function deleteGovernanceTemplate(settings: ApiSettings, templateId: string): Promise { - return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}`, { method: "DELETE" }); +export function deleteGovernanceTemplate(settings: ApiSettings, templateId: string, changeRequestId?: string | null): Promise { + const suffix = changeRequestId ? `?change_request_id=${encodeURIComponent(changeRequestId)}` : ""; + return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}${suffix}`, { method: "DELETE" }); +} + +export function fetchConfigurationChanges(settings: ApiSettings): Promise<{ requests: ConfigurationChangeRequest[]; history: ConfigurationChangeRecord[] }> { + return apiFetch(settings, "/api/v1/admin/configuration-changes"); +} + +export function fetchConfigurationChangesDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise { + const suffix = deltaSuffix(options); + return apiFetch(settings, `/api/v1/admin/configuration-changes/delta${suffix}`); +} + +export async function createConfigurationChangeRequest(settings: ApiSettings, payload: { + key: string; + value?: unknown; + dry_run?: boolean; + target?: Record; + reason?: string | null; +}): Promise { + const response = await apiFetch<{ request: ConfigurationChangeRequest }>(settings, "/api/v1/admin/configuration-change-requests", { + method: "POST", + body: JSON.stringify(payload) + }); + return response.request; +} + +export async function approveConfigurationChangeRequest(settings: ApiSettings, requestId: string, reason?: string | null): Promise { + const response = await apiFetch<{ request: ConfigurationChangeRequest }>(settings, `/api/v1/admin/configuration-change-requests/${encodeURIComponent(requestId)}/approve`, { + method: "POST", + body: JSON.stringify({ reason: reason ?? null }) + }); + return response.request; +} + +export function fetchConfigurationPackageCatalogValidation(settings: ApiSettings): Promise<{ validation: Record }> { + return apiFetch(settings, "/api/v1/admin/configuration-packages/catalog"); +} + +export function dryRunConfigurationPackage(settings: ApiSettings, payload: ConfigurationPackageRunPayload): Promise<{ + diagnostics: ConfigurationPackageDiagnostic[]; + required_data: ConfigurationPackageRequiredData[]; + plan: ConfigurationPackagePlanItem[]; +}> { + return apiFetch(settings, "/api/v1/admin/configuration-packages/dry-run", { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export function applyConfigurationPackage(settings: ApiSettings, payload: ConfigurationPackageRunPayload): Promise<{ + diagnostics: ConfigurationPackageDiagnostic[]; + created_refs: Record; + updated_refs: Record; +}> { + return apiFetch(settings, "/api/v1/admin/configuration-packages/apply", { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export function exportConfigurationPackage(settings: ApiSettings, payload: { + tenant_id?: string | null; + scopes?: string[]; + module_ids?: string[]; + object_refs?: string[]; +}): Promise<{ + fragments: ConfigurationPackageFragment[]; + data_requirements: ConfigurationPackageRequiredData[]; + diagnostics: ConfigurationPackageDiagnostic[]; +}> { + return apiFetch(settings, "/api/v1/admin/configuration-packages/export", { + method: "POST", + body: JSON.stringify(payload) + }); } diff --git a/webui/src/features/admin/AdminOverviewPanel.tsx b/webui/src/features/admin/AdminOverviewPanel.tsx index 6935319..ecdd476 100644 --- a/webui/src/features/admin/AdminOverviewPanel.tsx +++ b/webui/src/features/admin/AdminOverviewPanel.tsx @@ -5,7 +5,7 @@ import { Card } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui"; import { AdminPageLayout, adminErrorMessage } from "@govoplan/core-webui"; -export default function AdminOverviewPanel({ settings, onSelect, availableSections }: { settings: ApiSettings; onSelect: (section: string) => void; availableSections: ReadonlySet }) { +export default function AdminOverviewPanel({ settings, onSelect, availableSections }: {settings: ApiSettings;onSelect: (section: string) => void;availableSections: ReadonlySet;}) { const [overview, setOverview] = useState(null); const [error, setError] = useState(""); const [loading, setLoading] = useState(true); @@ -13,81 +13,112 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio async function load() { setLoading(true); setError(""); - try { setOverview(await fetchAdminOverview(settings)); } - catch (err) { setError(adminErrorMessage(err)); } - finally { setLoading(false); } + try {setOverview(await fetchAdminOverview(settings));} + catch (err) {setError(adminErrorMessage(err));} finally + {setLoading(false);} } - useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl]); + useEffect(() => {void load();}, [settings.accessToken, settings.apiBaseUrl]); const hasSystemMetrics = Boolean(overview && [ - overview.tenant_count, - overview.system_account_count, - overview.system_group_template_count, - overview.system_role_template_count - ].some((value) => value !== null && value !== undefined)); + overview.tenant_count, + overview.system_account_count, + overview.system_group_template_count, + overview.system_role_template_count]. + some((value) => value !== null && value !== undefined)); return ( - void load()} disabled={loading}>Reload}> + void load()} disabled={loading}>i18n:govoplan-admin.reload.cce71553}> {overview && <> {hasSystemMetrics && <> -
System
+
i18n:govoplan-admin.system.bc0792d8
- - - - + + + +
} - {hasSystemArea(availableSections) && + {hasAnySection(availableSections, platformSectionIds) &&
- {availableSections.has("system-settings") && onSelect("system-settings")} />} - {availableSections.has("system-tenants") && onSelect("system-tenants")} />} - {availableSections.has("system-roles") && onSelect("system-roles")} />} - {availableSections.has("system-role-templates") && onSelect("system-role-templates")} />} - {availableSections.has("system-groups") && onSelect("system-groups")} />} - {availableSections.has("system-users") && onSelect("system-users")} />} - {availableSections.has("system-mail-servers") && onSelect("system-mail-servers")} />} - {availableSections.has("system-retention") && onSelect("system-retention")} />} - {availableSections.has("system-modules") && onSelect("system-modules")} />} - {availableSections.has("system-audit") && onSelect("system-audit")} />} + {availableSections.has("system-modules") && onSelect("system-modules")} />} + {availableSections.has("system-configuration-packages") && onSelect("system-configuration-packages")} />} + {availableSections.has("system-settings") && onSelect("system-settings")} />} + {availableSections.has("system-configuration-changes") && onSelect("system-configuration-changes")} />} + {availableSections.has("system-audit") && onSelect("system-audit")} />}
} -
Active tenant: {overview.active_tenant_name}
+ {hasAnySection(availableSections, globalSectionIds) && +
+ {availableSections.has("system-tenants") && onSelect("system-tenants")} />} + {availableSections.has("system-roles") && onSelect("system-roles")} />} + {availableSections.has("system-role-templates") && onSelect("system-role-templates")} />} + {availableSections.has("system-groups") && onSelect("system-groups")} />} + {availableSections.has("system-users") && onSelect("system-users")} />} + {availableSections.has("system-file-connectors") && onSelect("system-file-connectors")} />} + {availableSections.has("system-mail-servers") && onSelect("system-mail-servers")} />} + {availableSections.has("system-retention") && onSelect("system-retention")} />} +
+
} + +
i18n:govoplan-admin.active_tenant.dfadf0aa {overview.active_tenant_name}
- - - - + + + +
- + {hasAnySection(availableSections, tenantSectionIds) &&
- {availableSections.has("tenant-settings") && onSelect("tenant-settings")} />} - {availableSections.has("tenant-roles") && onSelect("tenant-roles")} />} - {availableSections.has("tenant-groups") && onSelect("tenant-groups")} />} - {availableSections.has("tenant-users") && onSelect("tenant-users")} />} - {availableSections.has("tenant-mail-servers") && onSelect("tenant-mail-servers")} />} - {availableSections.has("tenant-retention") && onSelect("tenant-retention")} />} - {availableSections.has("tenant-api-keys") && onSelect("tenant-api-keys")} />} - {availableSections.has("tenant-audit") && onSelect("tenant-audit")} />} + {availableSections.has("tenant-roles") && onSelect("tenant-roles")} />} + {availableSections.has("tenant-groups") && onSelect("tenant-groups")} />} + {availableSections.has("tenant-users") && onSelect("tenant-users")} />} + {availableSections.has("tenant-file-connectors") && onSelect("tenant-file-connectors")} />} + {availableSections.has("tenant-mail-servers") && onSelect("tenant-mail-servers")} />} + {availableSections.has("tenant-api-keys") && onSelect("tenant-api-keys")} />} + {availableSections.has("tenant-retention") && onSelect("tenant-retention")} />} + {availableSections.has("tenant-settings") && onSelect("tenant-settings")} />} + {availableSections.has("tenant-audit") && onSelect("tenant-audit")} />}
-
+
} + + {hasAnySection(availableSections, groupSectionIds) && +
+ {availableSections.has("tenant-group-file-connectors") && onSelect("tenant-group-file-connectors")} />} + {availableSections.has("tenant-group-mail-servers") && onSelect("tenant-group-mail-servers")} />} + {availableSections.has("tenant-group-retention") && onSelect("tenant-group-retention")} />} +
+
} + + {hasAnySection(availableSections, userSectionIds) && +
+ {availableSections.has("tenant-user-file-connectors") && onSelect("tenant-user-file-connectors")} />} + {availableSections.has("tenant-user-mail-servers") && onSelect("tenant-user-mail-servers")} />} + {availableSections.has("tenant-user-retention") && onSelect("tenant-user-retention")} />} +
+
} } -
- ); +
); + } -function hasSystemArea(sections: ReadonlySet): boolean { - return ["system-settings", "system-tenants", "system-roles", "system-role-templates", "system-groups", "system-users", "system-mail-servers", "system-retention", "system-modules", "system-audit"].some((section) => sections.has(section)); +const platformSectionIds = ["system-modules", "system-configuration-packages", "system-settings", "system-configuration-changes", "system-audit"]; +const globalSectionIds = ["system-tenants", "system-roles", "system-role-templates", "system-groups", "system-users", "system-file-connectors", "system-mail-servers", "system-retention"]; +const tenantSectionIds = ["tenant-roles", "tenant-groups", "tenant-users", "tenant-file-connectors", "tenant-mail-servers", "tenant-api-keys", "tenant-retention", "tenant-settings", "tenant-audit"]; +const groupSectionIds = ["tenant-group-file-connectors", "tenant-group-mail-servers", "tenant-group-retention"]; +const userSectionIds = ["tenant-user-file-connectors", "tenant-user-mail-servers", "tenant-user-retention"]; + +function hasAnySection(sections: ReadonlySet, candidates: readonly string[]): boolean { + return candidates.some((section) => sections.has(section)); } -function Metric({ title, value, text }: { title: string; value: string | number; text: string }) { +function Metric({ title, value, text }: {title: string;value: string | number;text: string;}) { return {value}

{text}

; } -function AreaLink({ title, text, onClick }: { title: string; text: string; onClick: () => void }) { +function AreaLink({ title, text, onClick }: {title: string;text: string;onClick: () => void;}) { return ; } diff --git a/webui/src/features/admin/ConfigurationChangesPanel.tsx b/webui/src/features/admin/ConfigurationChangesPanel.tsx new file mode 100644 index 0000000..6387469 --- /dev/null +++ b/webui/src/features/admin/ConfigurationChangesPanel.tsx @@ -0,0 +1,170 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { Check, RefreshCw } from "lucide-react"; +import type { ApiSettings } from "@govoplan/core-webui"; +import { AdminPageLayout, Button, Card, StatusBadge, adminErrorMessage, formatDateTime, i18nMessage, mergeDeltaRows, useDeltaWatermarks } from "@govoplan/core-webui"; +import { + approveConfigurationChangeRequest, + fetchConfigurationChangesDelta, + type ConfigurationChangeRecord, + type ConfigurationChangeRequest } from +"../../api/admin"; + +const DELTA_KEY = "admin:configuration-changes"; + +export default function ConfigurationChangesPanel({ settings, canApprove }: {settings: ApiSettings;canApprove: boolean;}) { + const [requests, setRequests] = useState([]); + const [history, setHistory] = useState([]); + const requestsRef = useRef([]); + const historyRef = useRef([]); + const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks(); + const [loading, setLoading] = useState(true); + const [busyId, setBusyId] = useState(""); + const [error, setError] = useState(""); + const [message, setMessage] = useState(""); + + async function load() { + setLoading(true); + setError(""); + try { + let nextWatermark = getDeltaWatermark(DELTA_KEY); + let nextRequests = requestsRef.current; + let nextHistory = historyRef.current; + let hasMore = false; + do { + const payload = await fetchConfigurationChangesDelta(settings, { since: nextWatermark }); + nextRequests = payload.full ? payload.requests : mergeDeltaRows(nextRequests, payload.requests, payload.deleted, (request) => request.id, { sort: sortConfigurationRequests }); + nextHistory = payload.full ? payload.history : mergeDeltaRows(nextHistory, payload.history, payload.deleted, (record) => record.id, { sort: sortConfigurationHistory }); + nextWatermark = payload.watermark ?? null; + hasMore = payload.has_more; + } while (hasMore); + requestsRef.current = nextRequests; + historyRef.current = nextHistory; + setRequests(nextRequests); + setHistory(nextHistory); + setDeltaWatermark(DELTA_KEY, nextWatermark); + } catch (err) { + setError(adminErrorMessage(err)); + } finally { + setLoading(false); + } + } + + useEffect(() => { + requestsRef.current = []; + historyRef.current = []; + resetDeltaWatermark(DELTA_KEY); + void load(); + }, [settings.accessToken, settings.apiBaseUrl, resetDeltaWatermark]); + + async function approve(request: ConfigurationChangeRequest) { + setBusyId(request.id); + setError(""); + setMessage(""); + try { + await approveConfigurationChangeRequest(settings, request.id); + setMessage(i18nMessage("i18n:govoplan-admin.approved_value.52da808b", { value0: request.key })); + await load(); + } catch (err) { + setError(adminErrorMessage(err)); + } finally { + setBusyId(""); + } + } + + const pending = useMemo(() => requests.filter((item) => item.status !== "applied" && item.status !== "rejected"), [requests]); + + return ( + void load()} disabled={loading}> i18n:govoplan-admin.reload.cce71553}> + + +
+ + + + + + + + + + + + + {pending.map((request) => + + + + + + + + + )} + {!pending.length && } + +
i18n:govoplan-admin.setting.fb449f71i18n:govoplan-admin.status.bae7d5bei18n:govoplan-admin.requested.c26bf60fi18n:govoplan-admin.approvals.deb9d03ci18n:govoplan-admin.target.61ad50a9i18n:govoplan-admin.action.97c89a4d
{request.label || request.key}{request.key}{formatDateTime(request.requested_at)}{request.approvals.length}{targetLabel(request.target)} + {canApprove && request.status === "pending_approval" && + + } +
i18n:govoplan-admin.no_open_requests.580c95f9
+
+
+ + +
+ + + + + + + + + + + + + {history.map((record) => + + + + + + + + + )} + {!history.length && } + +
i18n:govoplan-admin.version.2da600bfi18n:govoplan-admin.setting.fb449f71i18n:govoplan-admin.status.bae7d5bei18n:govoplan-admin.applied.a3e4a569i18n:govoplan-admin.approvers.0e2de1fbi18n:govoplan-admin.target.61ad50a9
#{record.version}{record.key}{record.id}{formatDateTime(record.created_at)}{record.approval_user_ids.length || "-"}{targetLabel(record.target)}
i18n:govoplan-admin.no_applied_configuration_changes.6a3ae4a7
+
+
+
); + +} + +function statusTone(status: string): string { + if (status === "approved" || status === "applied") return "success"; + if (status === "pending_approval") return "warning"; + if (status === "rejected") return "error"; + return "inactive"; +} + +function targetLabel(target?: Record): string { + if (!target || !Object.keys(target).length) return "-"; + return Object.entries(target).map(([key, value]) => `${key}: ${String(value)}`).join(", "); +} + +function sortConfigurationRequests(left: ConfigurationChangeRequest, right: ConfigurationChangeRequest): number { + return new Date(right.updated_at || right.requested_at).getTime() - new Date(left.updated_at || left.requested_at).getTime(); +} + +function sortConfigurationHistory(left: ConfigurationChangeRecord, right: ConfigurationChangeRecord): number { + return right.version - left.version; +} diff --git a/webui/src/features/admin/ConfigurationPackagesPanel.tsx b/webui/src/features/admin/ConfigurationPackagesPanel.tsx new file mode 100644 index 0000000..a285751 --- /dev/null +++ b/webui/src/features/admin/ConfigurationPackagesPanel.tsx @@ -0,0 +1,374 @@ +import { useEffect, useMemo, useState } from "react"; +import { Check, Download, Play, RefreshCw } from "lucide-react"; +import type { ApiSettings } from "@govoplan/core-webui"; +import { AdminPageLayout, Button, Card, StatusBadge, adminErrorMessage, i18nMessage } from "@govoplan/core-webui"; +import { + applyConfigurationPackage, + createConfigurationChangeRequest, + dryRunConfigurationPackage, + exportConfigurationPackage, + fetchConfigurationPackageCatalogValidation, + type ConfigurationChangeRequest, + type ConfigurationPackageDiagnostic, + type ConfigurationPackagePlanItem, + type ConfigurationPackageRequiredData } from +"../../api/admin"; + +const SAMPLE_ACCESS_PACKAGE = { + package_id: "govoplan.access.minimal-office", + name: "i18n:govoplan-admin.minimal_office_access.af48f49a", + version: "0.1.0", + required_modules: [{ module_id: "access" }], + required_capabilities: ["configuration.provider", "access.configuration"], + fragments: [ + { + module_id: "access", + fragment_type: "roles", + payload: { + items: [ + { + slug: "case-clerk", + name: "i18n:govoplan-admin.case_clerk.b78a314a", + description: "i18n:govoplan-admin.handles_incoming_administrative_work.dc80c349", + permissions: ["admin:users:read"] + }] + + } + }, + { + module_id: "access", + fragment_type: "groups", + payload: { items: [{ slug: "front-office", name: "i18n:govoplan-admin.front_office.d9dcfee1" }] } + }, + { + module_id: "access", + fragment_type: "group_role_assignments", + payload: { items: [{ group: "front-office", role: "case-clerk" }] } + }] + +}; + +type DryRunResult = { + diagnostics: ConfigurationPackageDiagnostic[]; + required_data: ConfigurationPackageRequiredData[]; + plan: ConfigurationPackagePlanItem[]; +}; + +type ApplyResult = { + diagnostics: ConfigurationPackageDiagnostic[]; + created_refs: Record; + updated_refs: Record; +}; + +export default function ConfigurationPackagesPanel({ settings, canWrite }: {settings: ApiSettings;canWrite: boolean;}) { + const [catalogValidation, setCatalogValidation] = useState | null>(null); + const [packageText, setPackageText] = useState(() => JSON.stringify(SAMPLE_ACCESS_PACKAGE, null, 2)); + const [tenantId, setTenantId] = useState(""); + const [suppliedDataText, setSuppliedDataText] = useState("{}"); + const [changeRequestId, setChangeRequestId] = useState(""); + const [dryRun, setDryRun] = useState(null); + const [applyResult, setApplyResult] = useState(null); + const [exportText, setExportText] = useState(""); + const [lastRequest, setLastRequest] = useState(null); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(""); + const [error, setError] = useState(""); + const [message, setMessage] = useState(""); + + const parsedPackage = useMemo(() => parseObject(packageText), [packageText]); + const parsedSuppliedData = useMemo(() => parseObject(suppliedDataText), [suppliedDataText]); + const canRun = Boolean(parsedPackage.value && parsedSuppliedData.value); + + async function loadCatalog() { + setLoading(true); + setError(""); + try { + const response = await fetchConfigurationPackageCatalogValidation(settings); + setCatalogValidation(response.validation); + } catch (err) { + setError(adminErrorMessage(err)); + } finally { + setLoading(false); + } + } + + useEffect(() => {void loadCatalog();}, [settings.accessToken, settings.apiBaseUrl]); + + async function runDryRun() { + const manifest = requireParsedPackage(); + const suppliedData = requireParsedSuppliedData(); + if (!manifest || !suppliedData) return; + setBusy("dry-run"); + setError(""); + setMessage(""); + setApplyResult(null); + try { + const result = await dryRunConfigurationPackage(settings, runPayload(manifest, suppliedData)); + setDryRun(result); + setMessage(result.diagnostics.some((item) => item.severity === "blocker") ? "i18n:govoplan-admin.preflight_finished_with_blockers.7e2ca12b" : "i18n:govoplan-admin.preflight_passed.c0c99055"); + } catch (err) { + setError(adminErrorMessage(err)); + } finally { + setBusy(""); + } + } + + async function requestApproval() { + const manifest = requireParsedPackage(); + if (!manifest) return; + setBusy("approval"); + setError(""); + setMessage(""); + try { + const request = await createConfigurationChangeRequest(settings, { + key: "configuration_packages.apply", + value: manifest, + dry_run: true, + target: tenantId.trim() ? { tenant_id: tenantId.trim() } : {}, + reason: "i18n:govoplan-admin.configuration_package_apply.c270e5f3" + }); + setLastRequest(request); + setChangeRequestId(request.id); + setMessage(i18nMessage("i18n:govoplan-admin.change_request_created_value.4af6d3d2", { value0: request.id })); + } catch (err) { + setError(adminErrorMessage(err)); + } finally { + setBusy(""); + } + } + + async function applyPackage() { + const manifest = requireParsedPackage(); + const suppliedData = requireParsedSuppliedData(); + if (!manifest || !suppliedData) return; + setBusy("apply"); + setError(""); + setMessage(""); + try { + const result = await applyConfigurationPackage(settings, runPayload(manifest, suppliedData, changeRequestId.trim() || null)); + setApplyResult(result); + setMessage(result.diagnostics.some((item) => item.severity === "blocker") ? "i18n:govoplan-admin.apply_finished_with_blockers.78487a12" : "i18n:govoplan-admin.package_applied.63782ce7"); + } catch (err) { + setError(adminErrorMessage(err)); + } finally { + setBusy(""); + } + } + + async function exportAccessPackage() { + setBusy("export"); + setError(""); + setMessage(""); + try { + const result = await exportConfigurationPackage(settings, { + tenant_id: tenantId.trim() || null, + module_ids: ["access"], + scopes: tenantId.trim() ? ["tenant"] : ["system"] + }); + const exported = { + package_id: tenantId.trim() ? "govoplan.export.access-tenant" : "govoplan.export.access-system", + name: tenantId.trim() ? "i18n:govoplan-admin.exported_tenant_access_configuration.9122c85c" : "i18n:govoplan-admin.exported_system_access_configuration.2e1c6f4c", + version: "0.1.0", + required_modules: [{ module_id: "access" }], + required_capabilities: ["configuration.provider", "access.configuration"], + fragments: result.fragments, + data_requirements: result.data_requirements + }; + setExportText(JSON.stringify(exported, null, 2)); + setMessage(result.diagnostics.some((item) => item.severity === "blocker") ? "i18n:govoplan-admin.export_finished_with_blockers.e2f70611" : "i18n:govoplan-admin.access_configuration_exported.098f200d"); + } catch (err) { + setError(adminErrorMessage(err)); + } finally { + setBusy(""); + } + } + + function runPayload(manifest: Record, suppliedData: Record, requestId?: string | null) { + return { + package: manifest, + tenant_id: tenantId.trim() || null, + supplied_data: suppliedData, + change_request_id: requestId ?? null + }; + } + + function requireParsedPackage(): Record | null { + if (!parsedPackage.value) { + setError(parsedPackage.error || "i18n:govoplan-admin.package_json_must_be_an_object.db825bb3"); + return null; + } + return parsedPackage.value; + } + + function requireParsedSuppliedData(): Record | null { + if (!parsedSuppliedData.value) { + setError(parsedSuppliedData.error || "i18n:govoplan-admin.supplied_data_json_must_be_an_object.b265eb92"); + return null; + } + return parsedSuppliedData.value; + } + + return ( + void loadCatalog()} disabled={loading || Boolean(busy)}> i18n:govoplan-admin.reload.cce71553}> + + +
+ + {catalogValidation?.signed !== undefined && } + {catalogValidation?.trusted !== undefined && } +
+
{JSON.stringify(catalogValidation ?? {}, null, 2)}
+
+ + +
+ + +