Release v0.1.5
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
"""GovOPlaN generic administration module."""
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,2 @@
|
||||
"""Backend integration for the GovOPlaN admin module."""
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Admin-owned SQLAlchemy metadata and models."""
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
Reference in New Issue
Block a user