commit 2defb89bc165edc709d40432a9cb1391b3925308 Author: Albrecht Degering Date: Tue Jul 7 15:49:06 2026 +0200 Release v0.1.5 diff --git a/.gitea/ISSUE_TEMPLATE/bug_report.md b/.gitea/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..1be3329 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,35 @@ +--- +name: "Bug" +about: "Report a reproducible defect, regression, or incorrect behavior" +title: "[Bug] " +labels: + - type/bug + - status/triage + - module/admin +--- + +## Scope + +- Repository: +- Area/module: +- Affected version or commit: + +## Behavior + +Expected: + +Actual: + +## Reproduction + +1. +2. +3. + +## Evidence + +Logs, screenshots, traces, or failing test output: + +## Verification Target + +Command or workflow that should pass when fixed: diff --git a/.gitea/ISSUE_TEMPLATE/config.yaml b/.gitea/ISSUE_TEMPLATE/config.yaml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/config.yaml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.gitea/ISSUE_TEMPLATE/docs_workflow.md b/.gitea/ISSUE_TEMPLATE/docs_workflow.md new file mode 100644 index 0000000..166732a --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/docs_workflow.md @@ -0,0 +1,27 @@ +--- +name: "Docs / workflow" +about: "Request documentation, process, or developer workflow changes" +title: "[Docs] " +labels: + - type/docs + - status/triage + - module/admin + - area/docs +--- + +## Scope + +- Repository: +- Document or workflow: + +## Current State + +What is missing, unclear, duplicated, or stale? + +## Desired State + +What should the docs or workflow make clear? + +## Verification Target + +How should this be checked? diff --git a/.gitea/ISSUE_TEMPLATE/feature_request.md b/.gitea/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..37397b0 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,32 @@ +--- +name: "Feature" +about: "Propose new user-visible behavior or platform capability" +title: "[Feature] " +labels: + - type/feature + - status/triage + - module/admin +--- + +## Problem + +What user, operator, or developer problem should this solve? + +## Proposed Capability + +What should exist when this is done? + +## Ownership + +- Owning repository: +- Related module repositories: +- Extension point or integration boundary: + +## Acceptance Criteria + +- [ ] +- [ ] + +## Verification Target + +Command, scenario, or UI flow that should prove completion: diff --git a/.gitea/ISSUE_TEMPLATE/task.md b/.gitea/ISSUE_TEMPLATE/task.md new file mode 100644 index 0000000..9e68c67 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/task.md @@ -0,0 +1,28 @@ +--- +name: "Task" +about: "Track implementation, maintenance, or migration work" +title: "[Task] " +labels: + - type/task + - status/triage + - module/admin +--- + +## Objective + +What needs to be completed? + +## Scope + +- Owning repository: +- In-scope: +- Out-of-scope: + +## Checklist + +- [ ] +- [ ] + +## Verification Target + +Command or manual check: diff --git a/.gitea/ISSUE_TEMPLATE/tech_debt.md b/.gitea/ISSUE_TEMPLATE/tech_debt.md new file mode 100644 index 0000000..3e13ddd --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/tech_debt.md @@ -0,0 +1,25 @@ +--- +name: "Tech debt" +about: "Track cleanup, refactoring, risk reduction, or deferred engineering work" +title: "[Debt] " +labels: + - type/debt + - status/triage + - module/admin +--- + +## Current Cost + +What does this make harder, riskier, slower, or more fragile? + +## Desired Shape + +What should the code, tests, or architecture look like afterwards? + +## Constraints + +What behavior, compatibility, or module boundary must be preserved? + +## Verification Target + +Focused checks that should pass: diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..1984736 --- /dev/null +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ +## Issue + +Closes # + +## Summary + +- + +## Verification + +- + +## Notes + +Follow-up issues: diff --git a/README.md b/README.md new file mode 100644 index 0000000..3d503aa --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# 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/package.json b/package.json new file mode 100644 index 0000000..e6b94ac --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "@govoplan/admin-webui", + "version": "0.1.5", + "private": true, + "type": "module", + "main": "webui/src/index.ts", + "module": "webui/src/index.ts", + "types": "webui/src/index.ts", + "exports": { + ".": { + "types": "./webui/src/index.ts", + "import": "./webui/src/index.ts" + } + }, + "files": [ + "webui/src", + "README.md", + "LICENSE" + ], + "peerDependencies": { + "@govoplan/core-webui": "^0.1.5", + "lucide-react": "^0.555.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5e9755a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "govoplan-admin" +version = "0.1.5" +description = "GovOPlaN generic administration module." +readme = "README.md" +requires-python = ">=3.12" +authors = [{ name = "GovOPlaN" }] +dependencies = [ + "govoplan-core>=0.1.5", + "govoplan-access>=0.1.5", +] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +govoplan_admin = ["py.typed"] + +[project.entry-points."govoplan.modules"] +admin = "govoplan_admin.backend.manifest:get_manifest" + diff --git a/src/govoplan_admin.egg-info/PKG-INFO b/src/govoplan_admin.egg-info/PKG-INFO new file mode 100644 index 0000000..420a4a8 --- /dev/null +++ b/src/govoplan_admin.egg-info/PKG-INFO @@ -0,0 +1,35 @@ +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 new file mode 100644 index 0000000..80cf3b6 --- /dev/null +++ b/src/govoplan_admin.egg-info/SOURCES.txt @@ -0,0 +1,19 @@ +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 new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/govoplan_admin.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/src/govoplan_admin.egg-info/entry_points.txt b/src/govoplan_admin.egg-info/entry_points.txt new file mode 100644 index 0000000..49119b2 --- /dev/null +++ b/src/govoplan_admin.egg-info/entry_points.txt @@ -0,0 +1,2 @@ +[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 new file mode 100644 index 0000000..58ec5d6 --- /dev/null +++ b/src/govoplan_admin.egg-info/requires.txt @@ -0,0 +1,2 @@ +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 new file mode 100644 index 0000000..ae9ef12 --- /dev/null +++ b/src/govoplan_admin.egg-info/top_level.txt @@ -0,0 +1 @@ +govoplan_admin diff --git a/src/govoplan_admin/__init__.py b/src/govoplan_admin/__init__.py new file mode 100644 index 0000000..33f4211 --- /dev/null +++ b/src/govoplan_admin/__init__.py @@ -0,0 +1,2 @@ +"""GovOPlaN generic administration module.""" + diff --git a/src/govoplan_admin/__pycache__/__init__.cpython-312.pyc b/src/govoplan_admin/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..1c179ca Binary files /dev/null and b/src/govoplan_admin/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/govoplan_admin/__pycache__/__init__.cpython-313.pyc b/src/govoplan_admin/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..8f9aeb4 Binary files /dev/null and b/src/govoplan_admin/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/govoplan_admin/backend/__init__.py b/src/govoplan_admin/backend/__init__.py new file mode 100644 index 0000000..c7ceb0d --- /dev/null +++ b/src/govoplan_admin/backend/__init__.py @@ -0,0 +1,2 @@ +"""Backend integration for the GovOPlaN admin module.""" + diff --git a/src/govoplan_admin/backend/__pycache__/__init__.cpython-312.pyc b/src/govoplan_admin/backend/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..70ba3c4 Binary files /dev/null and b/src/govoplan_admin/backend/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/govoplan_admin/backend/__pycache__/__init__.cpython-313.pyc b/src/govoplan_admin/backend/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..31f8e84 Binary files /dev/null and b/src/govoplan_admin/backend/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/govoplan_admin/backend/__pycache__/governance.cpython-312.pyc b/src/govoplan_admin/backend/__pycache__/governance.cpython-312.pyc new file mode 100644 index 0000000..5174cf8 Binary files /dev/null and b/src/govoplan_admin/backend/__pycache__/governance.cpython-312.pyc differ diff --git a/src/govoplan_admin/backend/__pycache__/governance.cpython-313.pyc b/src/govoplan_admin/backend/__pycache__/governance.cpython-313.pyc new file mode 100644 index 0000000..6b29a00 Binary files /dev/null and b/src/govoplan_admin/backend/__pycache__/governance.cpython-313.pyc differ diff --git a/src/govoplan_admin/backend/__pycache__/manifest.cpython-312.pyc b/src/govoplan_admin/backend/__pycache__/manifest.cpython-312.pyc new file mode 100644 index 0000000..a62eaba Binary files /dev/null and b/src/govoplan_admin/backend/__pycache__/manifest.cpython-312.pyc differ diff --git a/src/govoplan_admin/backend/__pycache__/manifest.cpython-313.pyc b/src/govoplan_admin/backend/__pycache__/manifest.cpython-313.pyc new file mode 100644 index 0000000..2ba156d Binary files /dev/null and b/src/govoplan_admin/backend/__pycache__/manifest.cpython-313.pyc differ diff --git a/src/govoplan_admin/backend/api/__init__.py b/src/govoplan_admin/backend/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/govoplan_admin/backend/api/__pycache__/__init__.cpython-312.pyc b/src/govoplan_admin/backend/api/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..0035353 Binary files /dev/null and b/src/govoplan_admin/backend/api/__pycache__/__init__.cpython-312.pyc 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 new file mode 100644 index 0000000..8da0473 Binary files /dev/null and b/src/govoplan_admin/backend/api/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/govoplan_admin/backend/api/v1/__init__.py b/src/govoplan_admin/backend/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 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 new file mode 100644 index 0000000..19cfd53 Binary files /dev/null and b/src/govoplan_admin/backend/api/v1/__pycache__/__init__.cpython-312.pyc 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 new file mode 100644 index 0000000..ef805b7 Binary files /dev/null and b/src/govoplan_admin/backend/api/v1/__pycache__/__init__.cpython-313.pyc 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 new file mode 100644 index 0000000..550bec9 Binary files /dev/null and b/src/govoplan_admin/backend/api/v1/__pycache__/routes.cpython-312.pyc 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 new file mode 100644 index 0000000..f1ceb0c Binary files /dev/null and b/src/govoplan_admin/backend/api/v1/__pycache__/routes.cpython-313.pyc 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 new file mode 100644 index 0000000..db4e31a Binary files /dev/null and b/src/govoplan_admin/backend/api/v1/__pycache__/schemas.cpython-312.pyc 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 new file mode 100644 index 0000000..32d3f55 Binary files /dev/null and b/src/govoplan_admin/backend/api/v1/__pycache__/schemas.cpython-313.pyc differ diff --git a/src/govoplan_admin/backend/api/v1/routes.py b/src/govoplan_admin/backend/api/v1/routes.py new file mode 100644 index 0000000..6a07d84 --- /dev/null +++ b/src/govoplan_admin/backend/api/v1/routes.py @@ -0,0 +1,840 @@ +from __future__ import annotations + +import os +from pathlib import Path + +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_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.module_management import ( + PROTECTED_MODULES, + ModuleInstallPlanItem, + ModuleManagementError, + configured_enabled_modules, + module_install_plan_commands, + module_dependents, + module_uninstall_plan_item, + plan_desired_enabled_modules, + save_desired_enabled_modules, + save_module_install_plan, + saved_desired_enabled_modules, + saved_module_install_plan, +) +from govoplan_core.core.module_installer import ( + ModuleInstallerError, + cancel_module_installer_request, + default_installer_runtime_dir, + list_module_installer_runs, + list_module_installer_requests, + module_installer_daemon_status, + module_install_preflight, + module_installer_lock_status, + queue_module_installer_request, + read_module_installer_run, + read_module_installer_request, + retry_module_installer_request, +) +from govoplan_core.core.module_license import module_license_decision +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.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 +from govoplan_core.settings import settings as core_settings +from govoplan_core.tenancy.service import tenant_counts +from govoplan_tenancy.backend.db.models import Tenant + +from .schemas import ( + AdminOverviewResponse, + GovernanceTemplateCreateRequest, + GovernanceTemplateItem, + GovernanceTemplateListResponse, + GovernanceTemplateUpdateRequest, + MaintenanceModeItem, + ModuleCatalogItem, + ModuleCatalogResponse, + ModuleInstallPreflightResponse, + ModuleInstallerLockStatus, + ModuleInstallerDaemonStatus, + ModuleInstallerRequestCreateRequest, + ModuleInstallerRequestItem, + ModuleInstallerRequestListResponse, + ModuleInstallerRunListResponse, + ModuleInstallerRunSummary, + ModuleInstallPlanResponse, + ModuleInstallPlanUpdateRequest, + ModulePackageCatalogItem, + ModulePackageCatalogResponse, + ModuleStateUpdateRequest, + PrivacyRetentionPolicyItem, + SystemSettingsItem, + SystemSettingsUpdateRequest, +) + +router = APIRouter(prefix="/admin", tags=["admin"]) + + +def _access_administration() -> AccessAdministration: + registry = get_registry() + if registry is None or not registry.has_capability(CAPABILITY_ACCESS_ADMINISTRATION): + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Access administration capability is not configured") + capability = registry.require_capability(CAPABILITY_ACCESS_ADMINISTRATION) + if not isinstance(capability, AccessAdministration): + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Access administration capability is invalid") + return capability + + +def _request_registry(request: Request) -> PlatformRegistry: + registry = getattr(request.app.state, "govoplan_registry", None) + if not isinstance(registry, PlatformRegistry): + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="GovOPlaN module registry is not configured") + return registry + + +def _request_lifecycle(request: Request) -> ModuleLifecycleManager: + lifecycle = getattr(request.app.state, "govoplan_lifecycle", None) + if not isinstance(lifecycle, ModuleLifecycleManager): + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="GovOPlaN module lifecycle manager is not configured") + return lifecycle + + +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_400_BAD_REQUEST, detail=str(exc)) + + +def _governance_template_item(session: Session, item: GovernanceTemplate) -> GovernanceTemplateItem: + assignments = session.query(GovernanceTemplateAssignment).filter( + GovernanceTemplateAssignment.template_id == item.id + ).order_by(GovernanceTemplateAssignment.tenant_id.asc()).all() + return GovernanceTemplateItem( + id=item.id, + kind=item.kind, + slug=item.slug, + name=item.name, + description=item.description, + permissions=item.permissions or [], + effective_permission_count=effective_permission_count(item.permissions or [], level="tenant") if item.kind == "role" else 0, + is_active=item.is_active, + assignments=[{"tenant_id": row.tenant_id, "mode": row.mode} for row in assignments], + created_at=item.created_at, + updated_at=item.updated_at, + ) + + +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()] + configured_enabled = list(configured_enabled_modules(core_settings.enabled_modules)) + desired_enabled = list(saved_desired_enabled_modules(session, configured_enabled)) + lifecycle = getattr(request.app.state, "govoplan_lifecycle", None) + available = dict(lifecycle.available_modules) if isinstance(lifecycle, ModuleLifecycleManager) else available_module_manifests(ignore_load_errors=True) + dependents = module_dependents(available) + current_set = set(current_enabled) + desired_set = set(desired_enabled) + protected_set = set(PROTECTED_MODULES) + items: list[ModuleCatalogItem] = [] + for module_id, manifest in sorted(available.items(), key=lambda item: item[0]): + migration = manifest.migration_spec + frontend = manifest.frontend + items.append(ModuleCatalogItem( + id=manifest.id, + name=manifest.name, + version=manifest.version, + installed=True, + current_enabled=manifest.id in current_set, + desired_enabled=manifest.id in desired_set, + protected=manifest.id in protected_set, + restart_required=(manifest.id in current_set) != (manifest.id in desired_set), + dependencies=list(manifest.dependencies), + optional_dependencies=list(manifest.optional_dependencies), + dependents=list(dependents.get(manifest.id, ())), + permissions_count=len(manifest.permissions), + role_templates_count=len(manifest.role_templates), + route_contributed=manifest.route_factory is not None, + nav_count=len(manifest.nav_items), + frontend_package=frontend.package_name if frontend else None, + migration_module_id=migration.module_id if migration else None, + migration_script_location=migration.script_location if migration else None, + runtime_toggle_supported=True, + install_uninstall_supported=manifest.id not in protected_set, + )) + restart_required = current_set != desired_set + maintenance_mode = saved_maintenance_mode(session) + return ModuleCatalogResponse( + modules=items, + current_enabled=current_enabled, + desired_enabled=desired_enabled, + configured_enabled=configured_enabled, + protected_modules=list(PROTECTED_MODULES), + restart_required=restart_required, + runtime_toggle_supported=True, + install_uninstall_supported=True, + install_plan_supported=True, + package_mutation_supported=False, + maintenance_mode=MaintenanceModeItem.model_validate(maintenance_mode.as_dict()), + notes=notes or [ + "Enable/disable changes are applied to the running server and saved as startup state.", + "Installing or uninstalling Python/WebUI packages is planned here and applied by the separate installer daemon during maintenance mode.", + ], + ) + + +def _module_install_plan_response(session: Session, request: Request, *, notes: list[str] | None = None) -> ModuleInstallPlanResponse: + plan = saved_module_install_plan(session) + maintenance_mode = saved_maintenance_mode(session) + registry = _request_registry(request) + current_enabled = [manifest.id for manifest in registry.manifests()] + configured_enabled = list(configured_enabled_modules(core_settings.enabled_modules)) + desired_enabled = list(saved_desired_enabled_modules(session, configured_enabled)) + lifecycle = getattr(request.app.state, "govoplan_lifecycle", None) + available = dict(lifecycle.available_modules) if isinstance(lifecycle, ModuleLifecycleManager) else available_module_manifests(ignore_load_errors=True) + preflight = module_install_preflight( + plan=plan, + available=available, + current_enabled=current_enabled, + desired_enabled=desired_enabled, + maintenance_mode=maintenance_mode.enabled, + session=session, + webui_root=_webui_root(), + runtime_dir=default_installer_runtime_dir(core_settings.database_url), + ) + default_notes = [ + "The running server does not install or remove packages inside the FastAPI request.", + "Use govoplan-module-installer from an operator shell while maintenance mode is active.", + ] + if not maintenance_mode.enabled: + default_notes.append("Maintenance mode is currently off.") + return ModuleInstallPlanResponse( + items=[item.as_dict() for item in plan.items], + updated_at=plan.updated_at, + commands=list(module_install_plan_commands(plan)), + maintenance_mode=MaintenanceModeItem.model_validate(maintenance_mode.as_dict()), + preflight=ModuleInstallPreflightResponse.model_validate(preflight.as_dict()), + notes=notes or default_notes, + ) + + +def _webui_root() -> Path | None: + configured = os.getenv("GOVOPLAN_WEBUI_ROOT") + if configured: + return Path(configured).expanduser().resolve() + candidate = Path.cwd() / "webui" + return candidate if candidate.exists() else None + + +def _upsert_install_plan_item(session: Session, item: ModuleInstallPlanItem): + existing = saved_module_install_plan(session) + retained = [ + current + for current in existing.items + if not (current.status == "planned" and current.module_id == item.module_id) + ] + return save_module_install_plan(session, [*retained, item]) + + +def _catalog_plan_item(module_id: str) -> tuple[ModuleInstallPlanItem, dict[str, object]]: + result = validate_module_package_catalog() + if not result.get("valid"): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(result.get("error") or "Module package catalog is invalid."), + ) + for raw_item in result.get("modules", []): + if not isinstance(raw_item, dict): + continue + if raw_item.get("module_id") != module_id or raw_item.get("action") != "install": + continue + license_decision = module_license_decision(_catalog_license_features(raw_item)) + if not license_decision.get("allowed"): + missing = license_decision.get("missing_features") + missing_text = ", ".join(str(item) for item in missing) if isinstance(missing, list) else "required feature" + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"License does not allow installing {module_id}: {missing_text}.", + ) + notes = raw_item.get("notes") if isinstance(raw_item.get("notes"), str) else None + if license_decision.get("reason") and license_decision.get("missing_features"): + prefix = f"{notes}\n" if notes else "" + notes = f"{prefix}License warning: {license_decision['reason']}" + return ModuleInstallPlanItem( + module_id=str(raw_item["module_id"]), + action="install", + python_package=raw_item.get("python_package") if isinstance(raw_item.get("python_package"), str) else None, + python_ref=raw_item.get("python_ref") if isinstance(raw_item.get("python_ref"), str) else None, + webui_package=raw_item.get("webui_package") if isinstance(raw_item.get("webui_package"), str) else None, + webui_ref=raw_item.get("webui_ref") if isinstance(raw_item.get("webui_ref"), str) else None, + notes=notes, + ), result + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Catalog install entry not found: {module_id}") + + +def _catalog_license_features(raw_item: dict[str, object]) -> list[str]: + value = raw_item.get("license_features") + if not isinstance(value, list): + return [] + return [str(item).strip() for item in value if str(item).strip()] + + +def _module_package_catalog_item(raw_item: dict[str, object]) -> ModulePackageCatalogItem: + payload = dict(raw_item) + decision = module_license_decision(_catalog_license_features(raw_item)) + payload["license_allowed"] = bool(decision.get("allowed")) + payload["license_enforced"] = bool(decision.get("enforced")) + missing = decision.get("missing_features") + payload["license_missing_features"] = [str(item) for item in missing] if isinstance(missing, list) else [] + reason = decision.get("reason") + payload["license_reason"] = reason if isinstance(reason, str) else None + return ModulePackageCatalogItem.model_validate(payload) + + +@router.get("/overview", response_model=AdminOverviewResponse) +def admin_overview( + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope( + "admin:users:read", "admin:groups:read", "admin:roles:read", "admin:settings:read", + "admin:api_keys:read", "system:tenants:read", "system:accounts:read", "system:roles:read", "system:access:read", + "system:settings:read", "system:governance:read", "system:audit:read", + )), +): + tenant = session.get(Tenant, principal.tenant_id) + if tenant is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") + counts = tenant_counts(session, tenant.id) + access_admin = _access_administration() + capabilities = [item.scope for item in ALL_PERMISSIONS if has_scope(principal, item.scope)] + return AdminOverviewResponse( + active_tenant_id=tenant.id, + active_tenant_name=tenant.name, + tenant_count=session.query(Tenant).count() if has_scope(principal, "system:tenants:read") else None, + system_account_count=access_admin.system_account_count(session) if has_scope(principal, "system:accounts:read") or has_scope(principal, "system:access:read") else None, + system_group_template_count=session.query(GovernanceTemplate).filter(GovernanceTemplate.kind == "group").count() if has_scope(principal, "system:governance:read") else None, + system_role_template_count=session.query(GovernanceTemplate).filter(GovernanceTemplate.kind == "role").count() if has_scope(principal, "system:governance:read") else None, + user_count=counts["users"], + active_user_count=counts["active_users"], + group_count=counts["groups"], + role_count=access_admin.role_count_for_tenant(session, tenant.id), + active_api_key_count=access_admin.active_api_key_count_for_tenant(session, tenant.id), + capabilities=capabilities, + ) + + +@router.get("/system/modules", response_model=ModuleCatalogResponse) +def list_system_modules( + request: Request, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:settings:read")), +): + del principal + return _module_catalog_response(session, request) + + +@router.get("/system/modules/install-plan", response_model=ModuleInstallPlanResponse) +def read_module_install_plan( + request: Request, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:settings:read")), +): + del principal + return _module_install_plan_response(session, request) + + +@router.get("/system/modules/install-runs", response_model=ModuleInstallerRunListResponse) +def list_module_install_runs( + principal: ApiPrincipal = Depends(require_scope("system:settings:read")), +): + del principal + runtime_dir = default_installer_runtime_dir(core_settings.database_url) + return ModuleInstallerRunListResponse( + runs=[ + ModuleInstallerRunSummary.model_validate(item) + for item in list_module_installer_runs(runtime_dir=runtime_dir) + ], + lock=ModuleInstallerLockStatus.model_validate(module_installer_lock_status(runtime_dir=runtime_dir)), + ) + + +@router.get("/system/modules/install-runs/{run_id}") +def read_module_install_run_detail( + run_id: str, + principal: ApiPrincipal = Depends(require_scope("system:settings:read")), +): + del principal + runtime_dir = default_installer_runtime_dir(core_settings.database_url) + try: + return read_module_installer_run(runtime_dir=runtime_dir, run_id=run_id) + except ModuleInstallerError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + + +@router.get("/system/modules/install-requests", response_model=ModuleInstallerRequestListResponse) +def list_module_install_requests( + principal: ApiPrincipal = Depends(require_scope("system:settings:read")), +): + del principal + runtime_dir = default_installer_runtime_dir(core_settings.database_url) + return ModuleInstallerRequestListResponse( + requests=[ + ModuleInstallerRequestItem.model_validate(item) + for item in list_module_installer_requests(runtime_dir=runtime_dir) + ], + daemon=ModuleInstallerDaemonStatus.model_validate(module_installer_daemon_status(runtime_dir=runtime_dir)), + ) + + +@router.get("/system/modules/install-requests/{request_id}") +def read_module_install_request_detail( + request_id: str, + principal: ApiPrincipal = Depends(require_scope("system:settings:read")), +): + del principal + runtime_dir = default_installer_runtime_dir(core_settings.database_url) + try: + return read_module_installer_request(runtime_dir=runtime_dir, request_id=request_id) + except ModuleInstallerError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + + +@router.post("/system/modules/install-requests", response_model=ModuleInstallerRequestItem) +def create_module_install_request( + payload: ModuleInstallerRequestCreateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:settings:write")), +): + maintenance_mode = saved_maintenance_mode(session) + if not maintenance_mode.enabled: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Maintenance mode must be enabled before queuing installer requests.") + if not has_scope(principal, MAINTENANCE_ACCESS_SCOPE): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Queuing installer requests requires maintenance access.") + runtime_dir = default_installer_runtime_dir(core_settings.database_url) + request = queue_module_installer_request( + runtime_dir=runtime_dir, + requested_by=principal.user.id, + options=payload.options.model_dump(exclude_none=True), + ) + audit_from_principal( + session, + principal, + action="system_modules.install_request_queued", + scope="system", + object_type="module_install_request", + object_id=str(request["request_id"]), + details={"options": payload.options.model_dump(exclude_none=True)}, + ) + session.commit() + return ModuleInstallerRequestItem.model_validate(request) + + +@router.post("/system/modules/install-requests/{request_id}/cancel", response_model=ModuleInstallerRequestItem) +def cancel_module_install_request( + request_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:settings:write")), +): + maintenance_mode = saved_maintenance_mode(session) + if not maintenance_mode.enabled: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Maintenance mode must be enabled before changing installer requests.") + if not has_scope(principal, MAINTENANCE_ACCESS_SCOPE): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Changing installer requests requires maintenance access.") + runtime_dir = default_installer_runtime_dir(core_settings.database_url) + try: + request = cancel_module_installer_request( + runtime_dir=runtime_dir, + request_id=request_id, + cancelled_by=principal.user.id, + ) + except ModuleInstallerError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + audit_from_principal( + session, + principal, + action="system_modules.install_request_cancelled", + scope="system", + object_type="module_install_request", + object_id=request_id, + details={}, + ) + session.commit() + return ModuleInstallerRequestItem.model_validate(request) + + +@router.post("/system/modules/install-requests/{request_id}/retry", response_model=ModuleInstallerRequestItem) +def retry_module_install_request( + request_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:settings:write")), +): + maintenance_mode = saved_maintenance_mode(session) + if not maintenance_mode.enabled: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Maintenance mode must be enabled before changing installer requests.") + if not has_scope(principal, MAINTENANCE_ACCESS_SCOPE): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Changing installer requests requires maintenance access.") + runtime_dir = default_installer_runtime_dir(core_settings.database_url) + try: + request = retry_module_installer_request( + runtime_dir=runtime_dir, + request_id=request_id, + requested_by=principal.user.id, + ) + except ModuleInstallerError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + audit_from_principal( + session, + principal, + action="system_modules.install_request_retried", + scope="system", + object_type="module_install_request", + object_id=request_id, + details={"new_request_id": request["request_id"]}, + ) + session.commit() + return ModuleInstallerRequestItem.model_validate(request) + + +@router.get("/system/modules/package-catalog", response_model=ModulePackageCatalogResponse) +def read_module_package_catalog( + principal: ApiPrincipal = Depends(require_scope("system:settings:read")), +): + del principal + result = validate_module_package_catalog() + return ModulePackageCatalogResponse( + modules=[_module_package_catalog_item(item) for item in result["modules"] if isinstance(item, dict)], + configured=bool(result["configured"]), + valid=bool(result["valid"]), + 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, + 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, + 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, + warnings=[str(item) for item in result["warnings"]] if isinstance(result["warnings"], list) else [], + error=result["error"] if isinstance(result["error"], str) else None, + ) + + +@router.post("/system/modules/install-plan/catalog/{module_id}", response_model=ModuleInstallPlanResponse) +def plan_module_install_from_catalog( + module_id: str, + request: Request, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:settings:write")), +): + try: + item, validation = _catalog_plan_item(module_id) + 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 + audit_from_principal( + session, + principal, + action="system_modules.install_plan_catalog_added", + scope="system", + object_type="module_install_plan", + object_id=module_id, + details={"items": [item.as_dict() for item in plan.items]}, + ) + session.commit() + return _module_install_plan_response(session, request, notes=[f"Catalog install entry planned for {module_id}."]) + + +@router.post("/system/modules/{module_id}/uninstall-plan", response_model=ModuleInstallPlanResponse) +def plan_module_uninstall( + module_id: str, + request: Request, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:settings:write")), +): + registry = _request_registry(request) + lifecycle = _request_lifecycle(request) + current_enabled = {manifest.id for manifest in registry.manifests()} + 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.") + 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 + audit_from_principal( + session, + principal, + action="system_modules.uninstall_plan_added", + scope="system", + object_type="module_install_plan", + object_id=module_id, + details={"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}."]) + + +@router.put("/system/modules", response_model=ModuleCatalogResponse) +def update_system_modules( + request: Request, + payload: ModuleStateUpdateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:settings:write")), +): + lifecycle = _request_lifecycle(request) + available = lifecycle.available_modules + 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 + 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 + desired = save_desired_enabled_modules(session, result.enabled_modules) + audit_from_principal( + session, + principal, + action="system_modules.desired_state_updated", + 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), + }, + ) + session.commit() + notes = ["Module state saved and applied to the running server."] + if plan.added_dependencies: + notes.append("Required dependencies added automatically: " + ", ".join(plan.added_dependencies)) + if result.activated_modules: + notes.append("Activated: " + ", ".join(result.activated_modules)) + if result.deactivated_modules: + notes.append("Deactivated: " + ", ".join(result.deactivated_modules)) + return _module_catalog_response(session, request, notes=notes) + + +@router.put("/system/modules/install-plan", response_model=ModuleInstallPlanResponse) +def update_module_install_plan( + request: Request, + payload: ModuleInstallPlanUpdateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:settings:write")), +): + 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 + audit_from_principal( + session, + principal, + action="system_modules.install_plan_updated", + scope="system", + object_type="module_install_plan", + object_id="global", + details={"items": [item.as_dict() for item in plan.items]}, + ) + session.commit() + return _module_install_plan_response(session, request, notes=["Module package install plan saved."]) + + +@router.delete("/system/modules/install-plan", response_model=ModuleInstallPlanResponse) +def clear_module_install_plan( + request: Request, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:settings:write")), +): + plan = save_module_install_plan(session, ()) + audit_from_principal( + session, + principal, + action="system_modules.install_plan_cleared", + scope="system", + object_type="module_install_plan", + object_id="global", + details={"items": [item.as_dict() for item in plan.items]}, + ) + session.commit() + return _module_install_plan_response(session, request, notes=["Module package install plan cleared."]) + + +@router.get("/system/settings", response_model=SystemSettingsItem) +def read_system_settings( + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:settings:read")), +): + item = 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 {}, + ) + + +@router.patch("/system/settings", response_model=SystemSettingsItem) +def write_system_settings( + payload: SystemSettingsUpdateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:settings:write")), +): + item = get_system_settings(session) + 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 + 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: + current = saved_maintenance_mode(session) + next_mode = MaintenanceMode( + enabled=payload.maintenance_mode.enabled, + message=payload.maintenance_mode.message.strip() if payload.maintenance_mode.message else None, + ) + if current.enabled != next_mode.enabled and not has_scope(principal, MAINTENANCE_ACCESS_SCOPE): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Changing maintenance mode requires {MAINTENANCE_ACCESS_SCOPE}.", + ) + save_maintenance_mode(session, next_mode) + session.add(item) + audit_from_principal( + session, principal, action="system_settings.updated", scope="system", object_type="system_settings", object_id=item.id, + details=payload.model_dump(), + ) + session.commit() + return read_system_settings(session=session, principal=principal) + + +@router.get("/system/governance-templates", response_model=GovernanceTemplateListResponse) +def list_governance_templates( + kind: str | None = Query(default=None), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:governance:read")), +): + del principal + query = session.query(GovernanceTemplate) + if kind: + query = query.filter(GovernanceTemplate.kind == kind) + items = query.order_by(GovernanceTemplate.kind.asc(), GovernanceTemplate.name.asc()).all() + return GovernanceTemplateListResponse(templates=[_governance_template_item(session, item) for item in items]) + + +@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")), +): + try: + item = create_template( + session, + kind=payload.kind, + slug=payload.slug, + name=payload.name, + description=payload.description, + permissions=payload.permissions, + is_active=payload.is_active, + assignments=[assignment.model_dump() for assignment in payload.assignments], + ) + except (AdminConflictError, AdminValidationError) as exc: + raise _http_admin_error(exc) from exc + audit_from_principal( + session, + principal, + action="governance_template.created", + scope="system", + object_type=f"{payload.kind}_template", + object_id=item.id, + details={"slug": item.slug, "tenant_ids": [assignment.tenant_id for assignment in payload.assignments]}, + ) + session.commit() + return _governance_template_item(session, item) + + +@router.patch("/system/governance-templates/{template_id}", response_model=GovernanceTemplateItem) +def update_governance_template( + template_id: str, + payload: GovernanceTemplateUpdateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:governance:write")), +): + item = session.get(GovernanceTemplate, template_id) + if item is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Governance template not found") + try: + update_template( + session, + item, + name=payload.name, + description=payload.description, + permissions=payload.permissions, + is_active=payload.is_active, + assignments=[assignment.model_dump() for assignment in payload.assignments], + ) + except (AdminConflictError, AdminValidationError) as exc: + raise _http_admin_error(exc) from exc + audit_from_principal( + session, + principal, + action="governance_template.updated", + scope="system", + object_type=f"{item.kind}_template", + object_id=item.id, + details={"tenant_ids": [assignment.tenant_id for assignment in payload.assignments]}, + ) + session.commit() + return _governance_template_item(session, item) + + +@router.delete("/system/governance-templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT) +def remove_governance_template( + template_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:governance:write")), +): + item = session.get(GovernanceTemplate, template_id) + if item is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Governance template not found") + kind = item.kind + try: + delete_template(session, item) + except (AdminConflictError, AdminValidationError) as exc: + raise _http_admin_error(exc) from exc + audit_from_principal( + session, + principal, + action="governance_template.deleted", + scope="system", + object_type=f"{kind}_template", + object_id=template_id, + details={}, + ) + session.commit() + return None diff --git a/src/govoplan_admin/backend/api/v1/schemas.py b/src/govoplan_admin/backend/api/v1/schemas.py new file mode 100644 index 0000000..69abf62 --- /dev/null +++ b/src/govoplan_admin/backend/api/v1/schemas.py @@ -0,0 +1,360 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +RETENTION_DAY_KEYS = ( + "raw_campaign_json_retention_days", + "generated_eml_retention_days", + "stored_report_detail_retention_days", + "mock_mailbox_retention_days", + "audit_detail_retention_days", +) + + +RETENTION_POLICY_FIELD_KEYS = ( + "store_raw_campaign_json", + *RETENTION_DAY_KEYS, + "audit_detail_level", +) + + +def default_allow_lower_level_limits() -> dict[str, bool]: + return {key: True for key in RETENTION_POLICY_FIELD_KEYS} + + +def normalize_allow_lower_level_limits(value: Any, *, fill_defaults: bool) -> dict[str, bool] | None: + if value in (None, ""): + return default_allow_lower_level_limits() if fill_defaults else None + if not isinstance(value, dict): + raise ValueError("allow_lower_level_limits must be an object") + normalized = default_allow_lower_level_limits() if fill_defaults else {} + for key, allowed in value.items(): + clean_key = str(key) + if clean_key not in RETENTION_POLICY_FIELD_KEYS: + raise ValueError(f"Unknown retention policy field: {clean_key}") + normalized[clean_key] = bool(allowed) + return normalized + + +class PrivacyRetentionPolicyItem(BaseModel): + model_config = ConfigDict(extra="forbid") + + store_raw_campaign_json: bool = True + raw_campaign_json_retention_days: int | None = Field(default=None, ge=0) + generated_eml_retention_days: int | None = Field(default=None, ge=0) + stored_report_detail_retention_days: int | None = Field(default=None, ge=0) + mock_mailbox_retention_days: int | None = Field(default=None, ge=0) + audit_detail_retention_days: int | None = Field(default=None, ge=0) + audit_detail_level: Literal["full", "redacted", "minimal"] = "full" + allow_lower_level_limits: dict[str, bool] = Field(default_factory=default_allow_lower_level_limits) + + @field_validator("allow_lower_level_limits", mode="before") + @classmethod + def _normalize_allow_lower_level_limits(cls, value: Any) -> Any: + return normalize_allow_lower_level_limits(value, fill_defaults=True) + + +class MaintenanceModeItem(BaseModel): + model_config = ConfigDict(extra="forbid") + + enabled: bool = False + message: str | None = Field(default=None, max_length=500) + + +class AdminOverviewResponse(BaseModel): + active_tenant_id: str + active_tenant_name: str + tenant_count: int | None = None + system_account_count: int | None = None + system_group_template_count: int | None = None + system_role_template_count: int | None = None + user_count: int + active_user_count: int + group_count: int + role_count: int + active_api_key_count: int + capabilities: list[str] = Field(default_factory=list) + + +class SystemSettingsItem(BaseModel): + default_locale: str = "en" + allow_tenant_custom_groups: bool = True + allow_tenant_custom_roles: bool = True + allow_tenant_api_keys: bool = True + privacy_retention_policy: PrivacyRetentionPolicyItem = Field(default_factory=PrivacyRetentionPolicyItem) + maintenance_mode: MaintenanceModeItem = Field(default_factory=MaintenanceModeItem) + settings: dict[str, Any] = Field(default_factory=dict) + + +class SystemSettingsUpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + default_locale: str = Field(min_length=1, max_length=20) + allow_tenant_custom_groups: bool + allow_tenant_custom_roles: bool + allow_tenant_api_keys: bool + privacy_retention_policy: PrivacyRetentionPolicyItem | None = None + maintenance_mode: MaintenanceModeItem | None = None + + +class ModuleCatalogItem(BaseModel): + id: str + name: str + version: str + installed: bool = True + current_enabled: bool = False + desired_enabled: bool = False + protected: bool = False + restart_required: bool = False + dependencies: list[str] = Field(default_factory=list) + optional_dependencies: list[str] = Field(default_factory=list) + dependents: list[str] = Field(default_factory=list) + permissions_count: int = 0 + role_templates_count: int = 0 + route_contributed: bool = False + nav_count: int = 0 + frontend_package: str | None = None + migration_module_id: str | None = None + migration_script_location: str | None = None + runtime_toggle_supported: bool = False + install_uninstall_supported: bool = False + + +class ModuleCatalogResponse(BaseModel): + modules: list[ModuleCatalogItem] + current_enabled: list[str] + desired_enabled: list[str] + configured_enabled: list[str] + protected_modules: list[str] + restart_required: bool = False + runtime_toggle_supported: bool = False + install_uninstall_supported: bool = False + install_plan_supported: bool = False + package_mutation_supported: bool = False + maintenance_mode: MaintenanceModeItem = Field(default_factory=MaintenanceModeItem) + notes: list[str] = Field(default_factory=list) + + +class ModuleStateUpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + enabled_modules: list[str] = Field(default_factory=list) + + +class ModuleInstallPlanItem(BaseModel): + model_config = ConfigDict(extra="forbid") + + module_id: str = Field(min_length=1, max_length=120) + action: Literal["install", "uninstall"] + python_package: str | None = Field(default=None, max_length=200) + python_ref: str | None = Field(default=None, max_length=1000) + webui_package: str | None = Field(default=None, max_length=200) + webui_ref: str | None = Field(default=None, max_length=1000) + destroy_data: bool = False + status: Literal["planned", "applied", "blocked"] = "planned" + notes: str | None = Field(default=None, max_length=1000) + + +class ModuleInstallPreflightIssue(BaseModel): + severity: Literal["blocker", "warning", "info"] + code: str + message: str + module_id: str | None = None + + +class ModuleInstallChecklistItem(BaseModel): + id: str + label: str + status: Literal["done", "pending", "blocked", "warning"] + detail: str | None = None + + +class ModuleInstallPreflightResponse(BaseModel): + allowed: bool + maintenance_mode: bool + frontend_rebuild_required: bool + restart_required: bool + commands: list[str] = Field(default_factory=list) + rollback_commands: list[str] = Field(default_factory=list) + issues: list[ModuleInstallPreflightIssue] = Field(default_factory=list) + checklist: list[ModuleInstallChecklistItem] = Field(default_factory=list) + + +class ModuleInstallPlanResponse(BaseModel): + items: list[ModuleInstallPlanItem] = Field(default_factory=list) + updated_at: str | None = None + commands: list[str] = Field(default_factory=list) + maintenance_mode: MaintenanceModeItem = Field(default_factory=MaintenanceModeItem) + preflight: ModuleInstallPreflightResponse | None = None + notes: list[str] = Field(default_factory=list) + + +class ModuleInstallerLockStatus(BaseModel): + model_config = ConfigDict(extra="allow") + + locked: bool = False + path: str + + +class ModuleInstallerDaemonStatus(BaseModel): + model_config = ConfigDict(extra="allow") + + running: bool = False + status: str = "unknown" + path: str + + +class ModuleInstallerRunSummary(BaseModel): + run_id: str + status: str + started_at: str | None = None + finished_at: str | None = None + rollback_status: str | None = None + supervisor_status: str | None = None + error: str | None = None + record_path: str + commands_count: int = 0 + planned_modules: list[str] = Field(default_factory=list) + + +class ModuleInstallerRunListResponse(BaseModel): + runs: list[ModuleInstallerRunSummary] = Field(default_factory=list) + lock: ModuleInstallerLockStatus + + +class ModuleInstallerRequestOptions(BaseModel): + model_config = ConfigDict(extra="forbid") + + build_webui: bool = False + migrate_database: bool = True + webui_root: str | None = Field(default=None, max_length=1000) + npm_bin: str | None = Field(default=None, max_length=500) + database_backup_command: str | None = Field(default=None, max_length=4000) + database_restore_command: str | None = Field(default=None, max_length=4000) + database_restore_check_command: str | None = Field(default=None, max_length=4000) + activate_installed_modules: bool = True + remove_uninstalled_modules_from_desired: bool = True + restart_commands: list[str] = Field(default_factory=list) + health_urls: list[str] = Field(default_factory=list) + health_timeout_seconds: float = Field(default=60.0, ge=1, le=3600) + health_interval_seconds: float = Field(default=2.0, ge=0.2, le=120) + + +class ModuleInstallerRequestItem(BaseModel): + model_config = ConfigDict(extra="allow") + + request_id: str + status: str + created_at: str + requested_by: str | None = None + started_at: str | None = None + finished_at: str | None = None + cancelled_at: str | None = None + cancelled_by: str | None = None + retry_of: str | None = None + error: str | None = None + record_path: str | None = None + options: dict[str, Any] = Field(default_factory=dict) + + +class ModuleInstallerRequestListResponse(BaseModel): + requests: list[ModuleInstallerRequestItem] = Field(default_factory=list) + daemon: ModuleInstallerDaemonStatus + + +class ModuleInstallerRequestCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + options: ModuleInstallerRequestOptions = Field(default_factory=ModuleInstallerRequestOptions) + + +class ModulePackageCatalogItem(BaseModel): + module_id: str + name: str + description: str | None = None + version: str | None = None + action: Literal["install", "uninstall"] = "install" + python_package: str | None = None + python_ref: str | None = None + webui_package: str | None = None + webui_ref: str | None = None + license_features: list[str] = Field(default_factory=list) + license_allowed: bool = True + license_enforced: bool = False + license_missing_features: list[str] = Field(default_factory=list) + license_reason: str | None = None + notes: str | None = None + tags: list[str] = Field(default_factory=list) + + +class ModulePackageCatalogResponse(BaseModel): + modules: list[ModulePackageCatalogItem] = Field(default_factory=list) + configured: bool = False + valid: bool = True + path: str | None = None + source: str | None = None + source_type: str | None = None + channel: str | None = None + sequence: int | None = None + generated_at: str | None = None + expires_at: str | None = None + signed: bool = False + trusted: bool = False + key_id: str | None = None + warnings: list[str] = Field(default_factory=list) + error: str | None = None + + +class ModuleInstallPlanUpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + items: list[ModuleInstallPlanItem] = Field(default_factory=list) + + +class GovernanceAssignment(BaseModel): + tenant_id: str + mode: Literal["available", "required"] = "available" + + +class GovernanceTemplateItem(BaseModel): + id: str + kind: Literal["group", "role"] + slug: str + name: str + description: str | None = None + permissions: list[str] = Field(default_factory=list) + effective_permission_count: int = 0 + is_active: bool = True + assignments: list[GovernanceAssignment] = Field(default_factory=list) + created_at: datetime + updated_at: datetime + + +class GovernanceTemplateListResponse(BaseModel): + templates: list[GovernanceTemplateItem] + + +class GovernanceTemplateCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + kind: Literal["group", "role"] + slug: str + name: str = Field(min_length=1, max_length=255) + description: str | None = None + permissions: list[str] = Field(default_factory=list) + is_active: bool = True + assignments: list[GovernanceAssignment] = Field(default_factory=list) + + +class GovernanceTemplateUpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=255) + description: str | None = None + permissions: list[str] = Field(default_factory=list) + is_active: bool = True + assignments: list[GovernanceAssignment] = Field(default_factory=list) diff --git a/src/govoplan_admin/backend/db/__init__.py b/src/govoplan_admin/backend/db/__init__.py new file mode 100644 index 0000000..d10e58b --- /dev/null +++ b/src/govoplan_admin/backend/db/__init__.py @@ -0,0 +1,2 @@ +"""Admin-owned SQLAlchemy metadata and models.""" + diff --git a/src/govoplan_admin/backend/db/__pycache__/__init__.cpython-312.pyc b/src/govoplan_admin/backend/db/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..5a119ab Binary files /dev/null and b/src/govoplan_admin/backend/db/__pycache__/__init__.cpython-312.pyc 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 new file mode 100644 index 0000000..dff24f4 Binary files /dev/null and b/src/govoplan_admin/backend/db/__pycache__/__init__.cpython-313.pyc 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 new file mode 100644 index 0000000..c7b632f Binary files /dev/null and b/src/govoplan_admin/backend/db/__pycache__/models.cpython-312.pyc 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 new file mode 100644 index 0000000..613e4bb Binary files /dev/null and b/src/govoplan_admin/backend/db/__pycache__/models.cpython-313.pyc differ diff --git a/src/govoplan_admin/backend/db/models.py b/src/govoplan_admin/backend/db/models.py new file mode 100644 index 0000000..0b2f941 --- /dev/null +++ b/src/govoplan_admin/backend/db/models.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import uuid + +from sqlalchemy import Boolean, ForeignKey, JSON, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from govoplan_core.db.base import Base, TimestampMixin + + +def new_uuid() -> str: + return str(uuid.uuid4()) + + +class GovernanceTemplate(Base, TimestampMixin): + __tablename__ = "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) + kind: Mapped[str] = mapped_column(String(20), nullable=False, index=True) + slug: Mapped[str] = mapped_column(String(100), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text) + permissions: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + + +class GovernanceTemplateAssignment(Base, TimestampMixin): + __tablename__ = "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) + mode: Mapped[str] = mapped_column(String(20), default="available", nullable=False) + + +__all__ = ["GovernanceTemplate", "GovernanceTemplateAssignment", "new_uuid"] + diff --git a/src/govoplan_admin/backend/governance.py b/src/govoplan_admin/backend/governance.py new file mode 100644 index 0000000..6c2ed0f --- /dev/null +++ b/src/govoplan_admin/backend/governance.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +from sqlalchemy.orm import Session + +from govoplan_admin.backend.db.models import GovernanceTemplate, GovernanceTemplateAssignment +from govoplan_core.admin.common import AdminConflictError, AdminValidationError, slugify +from govoplan_core.core.access import ( + CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER, + AccessGovernanceMaterializer, + GovernanceTemplateMaterialization, +) +from govoplan_core.core.runtime import get_registry +from govoplan_core.security.permissions import validate_tenant_permissions +from govoplan_tenancy.backend.db.models import Tenant + +TEMPLATE_KINDS = {"group", "role"} +ASSIGNMENT_MODES = {"available", "required"} + + +def _governance_materializer() -> AccessGovernanceMaterializer: + registry = get_registry() + if registry is None or not registry.has_capability(CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER): + raise AdminValidationError("Access governance materializer capability is not configured.") + capability = registry.require_capability(CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER) + if not isinstance(capability, AccessGovernanceMaterializer): + raise AdminValidationError("Access governance materializer capability is invalid.") + return capability + + +def _materialization( + item: GovernanceTemplate, + *, + tenant_id: str, + required: bool = False, +) -> GovernanceTemplateMaterialization: + return GovernanceTemplateMaterialization( + template_id=item.id, + kind=item.kind, # type: ignore[arg-type] + tenant_id=tenant_id, + slug=item.slug, + name=item.name, + description=item.description, + permissions=tuple(item.permissions or []), + is_active=item.is_active, + required=required, + ) + + +def validate_template(kind: str, permissions: list[str]) -> list[str]: + if kind not in TEMPLATE_KINDS: + raise AdminValidationError("Template kind must be group or role.") + if kind == "group": + if permissions: + raise AdminValidationError("Group templates do not contain permissions; assign roles to groups inside each tenant.") + return [] + try: + return validate_tenant_permissions(permissions) + except ValueError as exc: + raise AdminValidationError(str(exc)) from exc + + +def create_template( + session: Session, + *, + kind: str, + slug: str, + name: str, + description: str | None, + permissions: list[str], + is_active: bool, + assignments: list[dict[str, str]], +) -> GovernanceTemplate: + normalized_slug = slugify(slug) + permissions = validate_template(kind, permissions) + exists = session.query(GovernanceTemplate).filter( + GovernanceTemplate.kind == kind, + GovernanceTemplate.slug == normalized_slug, + ).first() + if exists: + raise AdminConflictError(f"A {kind} template with slug {normalized_slug!r} already exists.") + item = GovernanceTemplate( + kind=kind, + slug=normalized_slug, + name=name.strip(), + description=description, + permissions=permissions, + is_active=is_active, + ) + session.add(item) + session.flush() + set_template_assignments(session, item, assignments) + return item + + +def update_template( + session: Session, + item: GovernanceTemplate, + *, + name: str, + description: str | None, + permissions: list[str], + is_active: bool, + assignments: list[dict[str, str]], +) -> GovernanceTemplate: + item.name = name.strip() + item.description = description + item.permissions = validate_template(item.kind, permissions) + item.is_active = is_active + set_template_assignments(session, item, assignments) + sync_template(session, item) + return item + + +def set_template_assignments( + session: Session, + item: GovernanceTemplate, + assignments: list[dict[str, str]], +) -> None: + desired: dict[str, str] = {} + for assignment in assignments: + tenant_id = assignment.get("tenant_id", "") + mode = assignment.get("mode", "available") + if mode not in ASSIGNMENT_MODES: + raise AdminValidationError("Template assignment mode must be available or required.") + tenant = session.get(Tenant, tenant_id) + if tenant is None: + raise AdminValidationError(f"Unknown tenant: {tenant_id}") + desired[tenant_id] = mode + + existing = { + row.tenant_id: row + for row in session.query(GovernanceTemplateAssignment) + .filter(GovernanceTemplateAssignment.template_id == item.id) + .all() + } + for tenant_id, row in list(existing.items()): + if tenant_id in desired: + row.mode = desired[tenant_id] + continue + _governance_materializer().remove_template(session, _materialization(item, tenant_id=tenant_id)) + session.delete(row) + + for tenant_id, mode in desired.items(): + if tenant_id not in existing: + session.add(GovernanceTemplateAssignment(template_id=item.id, tenant_id=tenant_id, mode=mode)) + session.flush() + sync_template(session, item) + + +def sync_template(session: Session, item: GovernanceTemplate) -> None: + assignments = session.query(GovernanceTemplateAssignment).filter( + GovernanceTemplateAssignment.template_id == item.id + ).all() + for assignment in assignments: + required = assignment.mode == "required" + _governance_materializer().sync_template( + session, + _materialization(item, tenant_id=assignment.tenant_id, required=required), + ) + session.flush() + + +def delete_template(session: Session, item: GovernanceTemplate) -> None: + assignments = session.query(GovernanceTemplateAssignment).filter( + GovernanceTemplateAssignment.template_id == item.id + ).all() + for assignment in assignments: + _governance_materializer().remove_template(session, _materialization(item, tenant_id=assignment.tenant_id)) + session.delete(item) diff --git a/src/govoplan_admin/backend/manifest.py b/src/govoplan_admin/backend/manifest.py new file mode 100644 index 0000000..d678232 --- /dev/null +++ b/src/govoplan_admin/backend/manifest.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from govoplan_admin.backend.db import models as admin_models # noqa: F401 - populate Admin ORM metadata +from govoplan_core.core.module_guards import persistent_table_uninstall_guard +from govoplan_core.core.modules import MigrationSpec, ModuleContext, ModuleManifest +from govoplan_core.db.base import Base + + +def _route_factory(context: ModuleContext): + del context + from govoplan_admin.backend.api.v1.routes import router + + return router + + +manifest = ModuleManifest( + id="admin", + name="Admin", + version="0.1.5", + dependencies=("access",), + route_factory=_route_factory, + migration_spec=MigrationSpec(module_id="admin", metadata=Base.metadata), + uninstall_guard_providers=( + persistent_table_uninstall_guard( + admin_models.GovernanceTemplate, + admin_models.GovernanceTemplateAssignment, + label="Admin", + ), + ), +) + + +def get_manifest() -> ModuleManifest: + return manifest diff --git a/src/govoplan_admin/py.typed b/src/govoplan_admin/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/govoplan_admin/py.typed @@ -0,0 +1 @@ + diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..5dbf5cb --- /dev/null +++ b/webui/package.json @@ -0,0 +1,30 @@ +{ + "name": "@govoplan/admin-webui", + "version": "0.1.5", + "private": true, + "type": "module", + "main": "src/index.ts", + "module": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "peerDependencies": { + "@govoplan/core-webui": "^0.1.5", + "lucide-react": "^0.555.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + }, + "devDependencies": { + "typescript": "^5.7.2" + } +} diff --git a/webui/src/api/admin.ts b/webui/src/api/admin.ts new file mode 100644 index 0000000..e1b2dfa --- /dev/null +++ b/webui/src/api/admin.ts @@ -0,0 +1,403 @@ +import type { ApiSettings } from "@govoplan/core-webui"; +import { apiFetch } from "@govoplan/core-webui"; + +export type PermissionItem = { + scope: string; + label: string; + description: string; + category: string; + level: "tenant" | "system"; + system_template_id?: string | null; + system_required?: boolean; +}; + +export type AdminOverview = { + active_tenant_id: string; + active_tenant_name: string; + tenant_count?: number | null; + system_account_count?: number | null; + system_group_template_count?: number | null; + system_role_template_count?: number | null; + user_count: number; + active_user_count: number; + group_count: number; + role_count: number; + active_api_key_count: number; + capabilities: string[]; +}; + +export type TenantAdminItem = { + id: string; + slug: string; + name: string; + description?: string | null; + default_locale: string; + settings: Record; + allow_custom_groups?: boolean | null; + allow_custom_roles?: boolean | null; + allow_api_keys?: boolean | null; + effective_governance: Record; + is_active: boolean; + counts: Record; + created_at: string; + updated_at: string; +}; + +export type PrivacyRetentionPolicyFieldKey = + | "store_raw_campaign_json" + | "raw_campaign_json_retention_days" + | "generated_eml_retention_days" + | "stored_report_detail_retention_days" + | "mock_mailbox_retention_days" + | "audit_detail_retention_days" + | "audit_detail_level"; + +export type PrivacyRetentionLimitPermissions = Record; +export type PrivacyRetentionLimitPermissionPatch = Partial; + +export type PrivacyRetentionPolicy = { + store_raw_campaign_json: boolean; + raw_campaign_json_retention_days?: number | null; + generated_eml_retention_days?: number | null; + stored_report_detail_retention_days?: number | null; + mock_mailbox_retention_days?: number | null; + audit_detail_retention_days?: number | null; + audit_detail_level: "full" | "redacted" | "minimal"; + allow_lower_level_limits: PrivacyRetentionLimitPermissions; +}; + +export type PrivacyRetentionPolicyPatch = Partial> & { + allow_lower_level_limits?: PrivacyRetentionLimitPermissionPatch; +}; + +export type MaintenanceMode = { + enabled: boolean; + message?: string | null; +}; + +export type SystemSettingsItem = { + default_locale: string; + allow_tenant_custom_groups: boolean; + allow_tenant_custom_roles: boolean; + allow_tenant_api_keys: boolean; + privacy_retention_policy: PrivacyRetentionPolicy; + maintenance_mode: MaintenanceMode; + settings: Record; +}; + +export type SystemSettingsUpdatePayload = { + default_locale: string; + allow_tenant_custom_groups: boolean; + allow_tenant_custom_roles: boolean; + allow_tenant_api_keys: boolean; + privacy_retention_policy?: PrivacyRetentionPolicy | null; + maintenance_mode?: MaintenanceMode | null; +}; + +export type ModuleCatalogItem = { + id: string; + name: string; + version: string; + installed: boolean; + current_enabled: boolean; + desired_enabled: boolean; + protected: boolean; + restart_required: boolean; + dependencies: string[]; + optional_dependencies: string[]; + dependents: string[]; + permissions_count: number; + role_templates_count: number; + route_contributed: boolean; + nav_count: number; + frontend_package?: string | null; + migration_module_id?: string | null; + migration_script_location?: string | null; + runtime_toggle_supported: boolean; + install_uninstall_supported: boolean; +}; + +export type ModuleCatalogResponse = { + modules: ModuleCatalogItem[]; + current_enabled: string[]; + desired_enabled: string[]; + configured_enabled: string[]; + protected_modules: string[]; + restart_required: boolean; + runtime_toggle_supported: boolean; + install_uninstall_supported: boolean; + install_plan_supported: boolean; + package_mutation_supported: boolean; + maintenance_mode: MaintenanceMode; + notes: string[]; +}; + +export type ModuleInstallPlanItem = { + module_id: string; + action: "install" | "uninstall"; + python_package?: string | null; + python_ref?: string | null; + webui_package?: string | null; + webui_ref?: string | null; + destroy_data: boolean; + status: "planned" | "applied" | "blocked"; + notes?: string | null; +}; + +export type ModuleInstallPreflightIssue = { + severity: "blocker" | "warning" | "info"; + code: string; + message: string; + module_id?: string | null; +}; + +export type ModuleInstallChecklistItem = { + id: string; + label: string; + status: "done" | "pending" | "blocked" | "warning"; + detail?: string | null; +}; + +export type ModuleInstallPreflight = { + allowed: boolean; + maintenance_mode: boolean; + frontend_rebuild_required: boolean; + restart_required: boolean; + commands: string[]; + rollback_commands: string[]; + issues: ModuleInstallPreflightIssue[]; + checklist: ModuleInstallChecklistItem[]; +}; + +export type ModuleInstallPlanResponse = { + items: ModuleInstallPlanItem[]; + updated_at?: string | null; + commands: string[]; + maintenance_mode: MaintenanceMode; + preflight?: ModuleInstallPreflight | null; + notes: string[]; +}; + +export type ModuleInstallerLockStatus = { + locked: boolean; + path: string; + [key: string]: unknown; +}; + +export type ModuleInstallerDaemonStatus = { + running: boolean; + status: string; + path: string; + [key: string]: unknown; +}; + +export type ModuleInstallerRunSummary = { + run_id: string; + status: string; + started_at?: string | null; + finished_at?: string | null; + rollback_status?: string | null; + supervisor_status?: string | null; + error?: string | null; + record_path: string; + commands_count: number; + planned_modules: string[]; +}; + +export type ModuleInstallerRunListResponse = { + runs: ModuleInstallerRunSummary[]; + lock: ModuleInstallerLockStatus; +}; + +export type ModuleInstallerRequestOptions = { + build_webui: boolean; + migrate_database: boolean; + webui_root?: string | null; + npm_bin?: string | null; + database_backup_command?: string | null; + database_restore_command?: string | null; + database_restore_check_command?: string | null; + activate_installed_modules: boolean; + remove_uninstalled_modules_from_desired: boolean; + restart_commands: string[]; + health_urls: string[]; + health_timeout_seconds: number; + health_interval_seconds: number; +}; + +export type ModuleInstallerRequestItem = { + request_id: string; + status: string; + created_at: string; + requested_by?: string | null; + started_at?: string | null; + finished_at?: string | null; + cancelled_at?: string | null; + cancelled_by?: string | null; + retry_of?: string | null; + error?: string | null; + record_path?: string | null; + options: Record; +}; + +export type ModuleInstallerRequestListResponse = { + requests: ModuleInstallerRequestItem[]; + daemon: ModuleInstallerDaemonStatus; +}; + +export type ModulePackageCatalogItem = { + module_id: string; + name: string; + description?: string | null; + version?: string | null; + action: "install" | "uninstall"; + python_package?: string | null; + python_ref?: string | null; + webui_package?: string | null; + webui_ref?: string | null; + license_features: string[]; + license_allowed: boolean; + license_enforced: boolean; + license_missing_features: string[]; + license_reason?: string | null; + notes?: string | null; + tags: string[]; +}; + +export type ModulePackageCatalogResponse = { + modules: ModulePackageCatalogItem[]; + configured: boolean; + valid: boolean; + path?: string | null; + source?: string | null; + source_type?: string | null; + channel?: string | null; + sequence?: number | null; + generated_at?: string | null; + expires_at?: string | null; + signed: boolean; + trusted: boolean; + key_id?: string | null; + warnings: string[]; + error?: string | null; +}; + +export type GovernanceAssignment = { + tenant_id: string; + mode: "available" | "required"; +}; + +export type GovernanceTemplateItem = { + id: string; + kind: "group" | "role"; + slug: string; + name: string; + description?: string | null; + permissions: string[]; + effective_permission_count: number; + is_active: boolean; + assignments: GovernanceAssignment[]; + created_at: string; + updated_at: string; +}; + +export function fetchAdminOverview(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/admin/overview"); +} + +export async function fetchPermissionCatalog(settings: ApiSettings): Promise { + const response = await apiFetch<{ permissions: PermissionItem[] }>(settings, "/api/v1/admin/permissions"); + return response.permissions; +} + +export async function fetchTenants(settings: ApiSettings): Promise { + const response = await apiFetch<{ tenants: TenantAdminItem[] }>(settings, "/api/v1/admin/tenants"); + return response.tenants; +} + +export function fetchSystemSettings(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/admin/system/settings"); +} + +export function updateSystemSettings(settings: ApiSettings, payload: SystemSettingsUpdatePayload): Promise { + return apiFetch(settings, "/api/v1/admin/system/settings", { method: "PATCH", body: JSON.stringify(payload) }); +} + +export function fetchModuleCatalog(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/admin/system/modules"); +} + +export function updateModuleState(settings: ApiSettings, enabledModules: string[]): Promise { + return apiFetch(settings, "/api/v1/admin/system/modules", { + method: "PUT", + body: JSON.stringify({ enabled_modules: enabledModules }) + }); +} + +export function fetchModuleInstallPlan(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/admin/system/modules/install-plan"); +} + +export function fetchModuleInstallerRuns(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/admin/system/modules/install-runs"); +} + +export function fetchModuleInstallerRequests(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/admin/system/modules/install-requests"); +} + +export function createModuleInstallerRequest(settings: ApiSettings, options: ModuleInstallerRequestOptions): Promise { + return apiFetch(settings, "/api/v1/admin/system/modules/install-requests", { + method: "POST", + body: JSON.stringify({ options }) + }); +} + +export function cancelModuleInstallerRequest(settings: ApiSettings, requestId: string): Promise { + return apiFetch(settings, `/api/v1/admin/system/modules/install-requests/${requestId}/cancel`, { method: "POST" }); +} + +export function retryModuleInstallerRequest(settings: ApiSettings, requestId: string): Promise { + return apiFetch(settings, `/api/v1/admin/system/modules/install-requests/${requestId}/retry`, { method: "POST" }); +} + +export function fetchModulePackageCatalog(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/admin/system/modules/package-catalog"); +} + +export function planModuleInstallFromCatalog(settings: ApiSettings, moduleId: string): Promise { + return apiFetch(settings, `/api/v1/admin/system/modules/install-plan/catalog/${encodeURIComponent(moduleId)}`, { method: "POST" }); +} + +export function planModuleUninstall(settings: ApiSettings, moduleId: string): Promise { + return apiFetch(settings, `/api/v1/admin/system/modules/${encodeURIComponent(moduleId)}/uninstall-plan`, { method: "POST" }); +} + +export function updateModuleInstallPlan(settings: ApiSettings, items: ModuleInstallPlanItem[]): Promise { + return apiFetch(settings, "/api/v1/admin/system/modules/install-plan", { + method: "PUT", + body: JSON.stringify({ items }) + }); +} + +export function clearModuleInstallPlan(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/admin/system/modules/install-plan", { method: "DELETE" }); +} + +export async function fetchGovernanceTemplates(settings: ApiSettings, kind?: "group" | "role"): Promise { + const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : ""; + const response = await apiFetch<{ templates: GovernanceTemplateItem[] }>(settings, `/api/v1/admin/system/governance-templates${suffix}`); + return response.templates; +} + +export function createGovernanceTemplate(settings: ApiSettings, payload: Omit): 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 { + 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" }); +} diff --git a/webui/src/features/admin/AdminOverviewPanel.tsx b/webui/src/features/admin/AdminOverviewPanel.tsx new file mode 100644 index 0000000..6935319 --- /dev/null +++ b/webui/src/features/admin/AdminOverviewPanel.tsx @@ -0,0 +1,93 @@ +import { useEffect, useState } from "react"; +import type { ApiSettings } from "@govoplan/core-webui"; +import { fetchAdminOverview, type AdminOverview } from "../../api/admin"; +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 }) { + const [overview, setOverview] = useState(null); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(true); + + async function load() { + setLoading(true); + setError(""); + try { setOverview(await fetchAdminOverview(settings)); } + catch (err) { setError(adminErrorMessage(err)); } + finally { setLoading(false); } + } + + 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)); + + return ( + void load()} disabled={loading}>Reload}> + {overview && <> + {hasSystemMetrics && <> +
System
+
+ + + + +
+ } + + {hasSystemArea(availableSections) && +
+ {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")} />} +
+
} + +
Active tenant: {overview.active_tenant_name}
+
+ + + + +
+ + +
+ {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")} />} +
+
+ } +
+ ); +} + +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)); +} + +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 }) { + return ; +} diff --git a/webui/src/features/admin/GovernanceTemplatesPanel.tsx b/webui/src/features/admin/GovernanceTemplatesPanel.tsx new file mode 100644 index 0000000..d5a28dc --- /dev/null +++ b/webui/src/features/admin/GovernanceTemplatesPanel.tsx @@ -0,0 +1,229 @@ +import { useEffect, useMemo, useState } from "react"; +import { Search, Pencil, Plus, Trash2 } from "lucide-react"; +import type { ApiSettings } from "@govoplan/core-webui"; +import { Button } from "@govoplan/core-webui"; +import { ConfirmDialog } from "@govoplan/core-webui"; +import { DataGrid, type DataGridColumn } from "@govoplan/core-webui"; +import { Dialog } from "@govoplan/core-webui"; +import { FormField } from "@govoplan/core-webui"; +import { StatusBadge } from "@govoplan/core-webui"; +import { + createGovernanceTemplate, + deleteGovernanceTemplate, + fetchGovernanceTemplates, + fetchPermissionCatalog, + fetchTenants, + updateGovernanceTemplate, + type GovernanceAssignment, + type GovernanceTemplateItem, + type PermissionItem, + type TenantAdminItem +} from "../../api/admin"; +import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, joinLabels } from "@govoplan/core-webui"; + +const emptyDraft = { + slug: "", + name: "", + description: "", + isActive: true, + permissions: [] as string[], + assignments: [] as GovernanceAssignment[] +}; + +export default function GovernanceTemplatesPanel({ + settings, + kind, + canWrite, + onAuthRefresh +}: { + settings: ApiSettings; + kind: "group" | "role"; + canWrite: boolean; + onAuthRefresh: () => Promise; +}) { + const [items, setItems] = useState([]); + const [tenants, setTenants] = useState([]); + const [permissions, setPermissions] = useState([]); + const [editing, setEditing] = useState(null); + const [viewing, setViewing] = useState(null); + const [deleting, setDeleting] = useState(null); + const [draft, setDraft] = useState(emptyDraft); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + + async function load() { + setLoading(true); + setError(""); + try { + const [nextItems, nextTenants, nextPermissions] = await Promise.all([ + fetchGovernanceTemplates(settings, kind), + fetchTenants(settings), + kind === "role" ? fetchPermissionCatalog(settings) : Promise.resolve([]) + ]); + setItems(nextItems); + setTenants(nextTenants); + setPermissions(nextPermissions.filter((item) => item.level === "tenant")); + } catch (err) { setError(adminErrorMessage(err)); } + finally { setLoading(false); } + } + + useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl, kind]); + + function openCreate() { + setDraft(emptyDraft); + setEditing("new"); + } + + function openEdit(item: GovernanceTemplateItem) { + setDraft({ + slug: item.slug, + name: item.name, + description: item.description || "", + isActive: item.is_active, + permissions: item.permissions, + assignments: item.assignments + }); + setEditing(item); + } + + function assignmentMode(tenantId: string): "none" | "available" | "required" { + return draft.assignments.find((item) => item.tenant_id === tenantId)?.mode ?? "none"; + } + + function setAssignment(tenantId: string, mode: "none" | "available" | "required") { + setDraft((current) => ({ + ...current, + assignments: mode === "none" + ? current.assignments.filter((item) => item.tenant_id !== tenantId) + : [...current.assignments.filter((item) => item.tenant_id !== tenantId), { tenant_id: tenantId, mode }] + })); + } + + async function save() { + setBusy(true); + setError(""); + try { + if (editing === "new") { + await createGovernanceTemplate(settings, { + kind, + slug: draft.slug, + name: draft.name, + description: draft.description || null, + permissions: kind === "role" ? draft.permissions : [], + is_active: draft.isActive, + assignments: draft.assignments + }); + setSuccess(`${kind === "group" ? "Group" : "Role"} template created.`); + } else if (editing) { + await updateGovernanceTemplate(settings, editing.id, { + name: draft.name, + description: draft.description || null, + permissions: kind === "role" ? draft.permissions : [], + is_active: draft.isActive, + assignments: draft.assignments + }); + setSuccess(`${draft.name} updated and synchronized to assigned tenants.`); + } + setEditing(null); + await load(); + await onAuthRefresh(); + } catch (err) { setError(adminErrorMessage(err)); } + finally { setBusy(false); } + } + + async function remove() { + if (!deleting) return; + setBusy(true); + setError(""); + try { + await deleteGovernanceTemplate(settings, deleting.id); + setSuccess(`${deleting.name} deleted.`); + setDeleting(null); + await load(); + await onAuthRefresh(); + } catch (err) { setError(adminErrorMessage(err)); } + finally { setBusy(false); } + } + + const columns = useMemo[]>(() => [ + { + id: "template", header: kind === "group" ? "Group template" : "Tenant role", width: "minmax(240px, 1.2fr)", minWidth: 210, resizable: true, sticky: "start", sortable: true, filterable: true, + value: (row) => `${row.name} ${row.slug}`, + render: (row) =>
{row.name}
{row.slug}
+ }, + { + id: "tenants", header: "Tenant availability", width: 320, minWidth: 210, maxWidth: 640, resizable: true, fill: true, sortable: true, filterable: true, + value: (row) => row.assignments.map((assignment) => tenants.find((tenant) => tenant.id === assignment.tenant_id)?.name || assignment.tenant_id).join(", ") || "—", + render: (row) => row.assignments.length ? row.assignments.map((assignment) => { + const tenant = tenants.find((item) => item.id === assignment.tenant_id); + return `${tenant?.name || assignment.tenant_id} (${assignment.mode})`; + }).join(", ") : "—" + }, + ...(kind === "role" ? [{ + id: "permissions", header: "Permissions", width: 120, resizable: false, sortable: true, filterable: true, filterType: "integer" as const, + value: (row: GovernanceTemplateItem) => row.effective_permission_count + }] : []), + { id: "status", header: "Status", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => }, + { + id: "actions", header: "Actions", width: 150, sticky: "end", resizable: false, align: "right", + render: (row) =>
+ } onClick={() => setViewing(row)} /> + } onClick={() => openEdit(row)} disabled={!canWrite} /> + } variant="danger" onClick={() => setDeleting(row)} disabled={!canWrite} /> +
+ } + ], [canWrite, kind, tenants]); + + const title = kind === "group" ? "Central groups" : "Tenant roles"; + const description = kind === "group" + ? "Centrally defined group identities that are provisioned into selected tenants as available or required definitions. Membership remains tenant-local." + : "Centrally governed tenant roles that are provisioned into selected tenants as available or required definitions."; + + return ( + <> + } variant="primary" onClick={openCreate} disabled={!canWrite} />} + > +
+ row.id} emptyText={`No central ${kind} templates found.`} /> +
+
+ + !busy && setEditing(null)} + className="admin-dialog admin-dialog-wide" + footer={<>} + > +
+ setDraft({ ...draft, name: event.target.value })} /> + setDraft({ ...draft, slug: event.target.value })} /> + +