chore: sync GovOPlaN module split state

This commit is contained in:
2026-07-10 12:51:16 +02:00
parent 0f2a9beca7
commit 970625edff
43 changed files with 2959 additions and 518 deletions

View File

@@ -2,17 +2,33 @@ from __future__ import annotations
import os
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy.orm import Session
from govoplan_admin.backend.governance import create_template, delete_template, update_template
from govoplan_core.admin.settings import get_system_settings
from govoplan_access.backend.auth.dependencies import ApiPrincipal, has_scope, require_any_scope, require_scope
from govoplan_core.audit.logging import audit_from_principal
from govoplan_access.auth import ApiPrincipal, has_scope, require_any_scope, require_scope
from govoplan_core.audit.logging import audit_from_principal, audit_operation_context
from govoplan_admin.backend.db.models import GovernanceTemplate, GovernanceTemplateAssignment
from govoplan_core.admin.common import AdminConflictError, AdminValidationError
from govoplan_core.core.access import CAPABILITY_ACCESS_ADMINISTRATION, AccessAdministration
from govoplan_core.core.change_sequence import (
decode_sequence_watermark,
encode_sequence_watermark,
max_sequence_id,
record_change,
sequence_entries_since,
sequence_watermark_is_expired,
)
from govoplan_core.core.pagination import KeysetCursorError, decode_keyset_cursor, encode_keyset_cursor, keyset_query_fingerprint
from govoplan_core.core.configuration_control import (
ConfigurationChangeApproval,
ConfigurationControlError,
ensure_configuration_change_allowed,
record_configuration_change_applied,
)
from govoplan_core.core.module_management import (
PROTECTED_MODULES,
ModuleInstallPlanItem,
@@ -41,13 +57,20 @@ from govoplan_core.core.module_installer import (
read_module_installer_request,
retry_module_installer_request,
)
from govoplan_core.core.module_license import module_license_decision
from govoplan_core.core.module_license import module_license_decision, module_license_diagnostics
from govoplan_core.core.module_package_catalog import record_module_package_catalog_acceptance, validate_module_package_catalog
from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, MaintenanceMode, saved_maintenance_mode, save_maintenance_mode
from govoplan_core.core.lifecycle import ModuleLifecycleManager
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.runtime import get_registry
from govoplan_core.db.session import get_session
from govoplan_core.i18n import (
i18n_settings,
normalize_enabled_language_codes,
normalize_language_packages,
system_i18n_payload,
update_i18n_settings,
)
from govoplan_core.privacy.retention import privacy_policy_from_settings, set_privacy_policy
from govoplan_core.security.permissions import ALL_PERMISSIONS, effective_permission_count
from govoplan_core.server.registry import available_module_manifests
@@ -59,6 +82,7 @@ from .schemas import (
AdminOverviewResponse,
GovernanceTemplateCreateRequest,
GovernanceTemplateItem,
GovernanceTemplateListDeltaResponse,
GovernanceTemplateListResponse,
GovernanceTemplateUpdateRequest,
MaintenanceModeItem,
@@ -76,14 +100,33 @@ from .schemas import (
ModuleInstallPlanUpdateRequest,
ModulePackageCatalogItem,
ModulePackageCatalogResponse,
ModuleLicenseDiagnostics,
ModuleStateUpdateRequest,
PrivacyRetentionPolicyItem,
SystemSettingsDeltaResponse,
SystemSettingsItem,
SystemSettingsUpdateRequest,
)
router = APIRouter(prefix="/admin", tags=["admin"])
ADMIN_MODULE_ID = "admin"
SYSTEM_SETTINGS_COLLECTION = "admin.system_settings"
SYSTEM_SETTINGS_RESOURCE = "system_settings_section"
GOVERNANCE_TEMPLATES_COLLECTION = "admin.governance_templates"
GOVERNANCE_TEMPLATE_RESOURCE = "governance_template"
INSTALLER_RUNS_CURSOR_SCOPE = "admin.installer.runs.v1"
INSTALLER_REQUESTS_CURSOR_SCOPE = "admin.installer.requests.v1"
DEFAULT_INSTALLER_HISTORY_PAGE_SIZE = 25
SYSTEM_SETTINGS_SECTIONS = (
"defaults",
"tenant_capabilities",
"languages",
"privacy_retention_policy",
"maintenance_mode",
"settings",
)
def _access_administration() -> AccessAdministration:
registry = get_registry()
@@ -113,10 +156,136 @@ def _http_admin_error(exc: Exception) -> HTTPException:
if isinstance(exc, AdminConflictError):
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
if isinstance(exc, AdminValidationError):
return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc))
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
def _configuration_control_http_error(exc: ConfigurationControlError) -> HTTPException:
return HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={"code": exc.code, "message": str(exc), "plan": exc.plan},
)
def _admin_delta_watermark(session: Session, collections: tuple[str, ...]) -> str:
return encode_sequence_watermark(max_sequence_id(session, module_id=ADMIN_MODULE_ID, collections=collections))
def _admin_delta_entries(session: Session, *, collections: tuple[str, ...], since: str, limit: int):
try:
since_sequence = decode_sequence_watermark(since)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
if sequence_watermark_is_expired(
session,
since=since_sequence,
module_id=ADMIN_MODULE_ID,
collections=collections,
):
return None, False
entries_plus_one = sequence_entries_since(
session,
since=since_sequence,
module_id=ADMIN_MODULE_ID,
collections=collections,
limit=limit + 1,
)
has_more = len(entries_plus_one) > limit
return entries_plus_one[:limit], has_more
def _admin_delta_response_watermark(session: Session, *, collections: tuple[str, ...], entries, has_more: bool) -> str:
return encode_sequence_watermark(entries[-1].id) if has_more and entries else _admin_delta_watermark(session, collections)
def _history_cursor_fingerprint(scope: str, *, page_size: int) -> str:
return keyset_query_fingerprint(scope, {"page_size": page_size, "sort": "id.desc"})
def _window_history_records(records: list[dict[str, object]], *, id_key: str, scope: str, page_size: int, cursor: str | None):
start = 0
full = False
fingerprint = _history_cursor_fingerprint(scope, page_size=page_size)
if cursor:
try:
values = decode_keyset_cursor(scope, cursor, fingerprint=fingerprint)
except KeysetCursorError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
after_id = values.get(id_key) if values else None
if not isinstance(after_id, str):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid pagination cursor")
matching_index = next((index for index, item in enumerate(records) if str(item.get(id_key) or "") == after_id), None)
if matching_index is None:
full = True
else:
start = matching_index + 1
page = records[start:start + page_size]
next_cursor = None
if start + page_size < len(records) and page:
next_cursor = encode_keyset_cursor(scope, fingerprint=fingerprint, values={id_key: str(page[-1].get(id_key) or ""), "page_size": page_size})
return page, next_cursor, full
def _system_settings_item(session: Session) -> SystemSettingsItem:
item = get_system_settings(session)
policy = privacy_policy_from_settings(item)
maintenance_mode = saved_maintenance_mode(session)
i18n_payload = system_i18n_payload(item)
return SystemSettingsItem(
default_locale=item.default_locale,
allow_tenant_custom_groups=item.allow_tenant_custom_groups,
allow_tenant_custom_roles=item.allow_tenant_custom_roles,
allow_tenant_api_keys=item.allow_tenant_api_keys,
privacy_retention_policy=PrivacyRetentionPolicyItem.model_validate(policy.model_dump(mode="json")),
maintenance_mode=MaintenanceModeItem.model_validate(maintenance_mode.as_dict()),
available_languages=i18n_payload["available_languages"],
enabled_language_codes=i18n_payload["enabled_languages"],
settings=item.settings or {},
)
def _system_settings_sections(item: SystemSettingsItem) -> dict[str, Any]:
payload = item.model_dump(mode="json")
return {
"defaults": {"default_locale": payload["default_locale"]},
"tenant_capabilities": {
"allow_tenant_custom_groups": payload["allow_tenant_custom_groups"],
"allow_tenant_custom_roles": payload["allow_tenant_custom_roles"],
"allow_tenant_api_keys": payload["allow_tenant_api_keys"],
},
"languages": {
"available_languages": payload["available_languages"],
"enabled_language_codes": payload["enabled_language_codes"],
},
"privacy_retention_policy": payload["privacy_retention_policy"],
"maintenance_mode": payload["maintenance_mode"],
"settings": payload["settings"],
}
def _record_system_settings_section_changes(
session: Session,
*,
before: dict[str, Any],
after: dict[str, Any],
principal: ApiPrincipal,
) -> None:
for section in SYSTEM_SETTINGS_SECTIONS:
if before.get(section) == after.get(section):
continue
record_change(
session,
module_id=ADMIN_MODULE_ID,
collection=SYSTEM_SETTINGS_COLLECTION,
resource_type=SYSTEM_SETTINGS_RESOURCE,
resource_id=section,
operation="updated",
actor_type="user",
actor_id=principal.user.id,
payload={"section": section},
)
def _governance_template_item(session: Session, item: GovernanceTemplate) -> GovernanceTemplateItem:
assignments = session.query(GovernanceTemplateAssignment).filter(
GovernanceTemplateAssignment.template_id == item.id
@@ -136,6 +305,28 @@ def _governance_template_item(session: Session, item: GovernanceTemplate) -> Gov
)
def _record_governance_template_change(session: Session, *, item: GovernanceTemplate, operation: str, principal: ApiPrincipal) -> None:
record_change(
session,
module_id=ADMIN_MODULE_ID,
collection=GOVERNANCE_TEMPLATES_COLLECTION,
resource_type=GOVERNANCE_TEMPLATE_RESOURCE,
resource_id=item.id,
operation=operation,
actor_type="user",
actor_id=principal.user.id,
payload={"kind": item.kind, "slug": item.slug, "name": item.name, "is_active": item.is_active},
)
def _governance_template_deleted_entries(entries, visible_template_ids: set[str]):
return [
{"id": entry.resource_id, "resource_type": entry.resource_type or GOVERNANCE_TEMPLATE_RESOURCE}
for entry in entries
if entry.resource_id and (entry.operation == "deleted" or entry.resource_id not in visible_template_ids)
]
def _module_catalog_response(session: Session, request: Request, *, notes: list[str] | None = None) -> ModuleCatalogResponse:
registry = _request_registry(request)
current_enabled = [manifest.id for manifest in registry.manifests()]
@@ -251,7 +442,7 @@ def _catalog_plan_item(module_id: str) -> tuple[ModuleInstallPlanItem, dict[str,
result = validate_module_package_catalog()
if not result.get("valid"):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(result.get("error") or "Module package catalog is invalid."),
)
for raw_item in result.get("modules", []):
@@ -302,6 +493,14 @@ def _module_package_catalog_item(raw_item: dict[str, object]) -> ModulePackageCa
return ModulePackageCatalogItem.model_validate(payload)
def _catalog_required_license_features(result: dict[str, object]) -> list[str]:
required: list[str] = []
for raw_item in result.get("modules", []):
if isinstance(raw_item, dict):
required.extend(_catalog_license_features(raw_item))
return list(dict.fromkeys(required))
@router.get("/overview", response_model=AdminOverviewResponse)
def admin_overview(
session: Session = Depends(get_session),
@@ -355,16 +554,23 @@ def read_module_install_plan(
@router.get("/system/modules/install-runs", response_model=ModuleInstallerRunListResponse)
def list_module_install_runs(
page_size: int = Query(default=DEFAULT_INSTALLER_HISTORY_PAGE_SIZE, ge=1, le=100),
cursor: str | None = None,
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
):
del principal
runtime_dir = default_installer_runtime_dir(core_settings.database_url)
records = list(list_module_installer_runs(runtime_dir=runtime_dir, limit=1000))
page, next_cursor, full = _window_history_records(records, id_key="run_id", scope=INSTALLER_RUNS_CURSOR_SCOPE, page_size=page_size, cursor=cursor)
return ModuleInstallerRunListResponse(
runs=[
ModuleInstallerRunSummary.model_validate(item)
for item in list_module_installer_runs(runtime_dir=runtime_dir)
for item in page
],
lock=ModuleInstallerLockStatus.model_validate(module_installer_lock_status(runtime_dir=runtime_dir)),
cursor=cursor,
next_cursor=next_cursor,
full=full,
)
@@ -383,16 +589,23 @@ def read_module_install_run_detail(
@router.get("/system/modules/install-requests", response_model=ModuleInstallerRequestListResponse)
def list_module_install_requests(
page_size: int = Query(default=DEFAULT_INSTALLER_HISTORY_PAGE_SIZE, ge=1, le=100),
cursor: str | None = None,
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
):
del principal
runtime_dir = default_installer_runtime_dir(core_settings.database_url)
records = list(list_module_installer_requests(runtime_dir=runtime_dir, limit=1000))
page, next_cursor, full = _window_history_records(records, id_key="request_id", scope=INSTALLER_REQUESTS_CURSOR_SCOPE, page_size=page_size, cursor=cursor)
return ModuleInstallerRequestListResponse(
requests=[
ModuleInstallerRequestItem.model_validate(item)
for item in list_module_installer_requests(runtime_dir=runtime_dir)
for item in page
],
daemon=ModuleInstallerDaemonStatus.model_validate(module_installer_daemon_status(runtime_dir=runtime_dir)),
cursor=cursor,
next_cursor=next_cursor,
full=full,
)
@@ -433,7 +646,12 @@ def create_module_install_request(
scope="system",
object_type="module_install_request",
object_id=str(request["request_id"]),
details={"options": payload.options.model_dump(exclude_none=True)},
details=audit_operation_context(
request_id=request.get("request_id"),
outcome=request.get("status"),
trace=request.get("trace") if isinstance(request.get("trace"), dict) else None,
options=payload.options.model_dump(exclude_none=True),
),
)
session.commit()
return ModuleInstallerRequestItem.model_validate(request)
@@ -458,7 +676,7 @@ def cancel_module_install_request(
cancelled_by=principal.user.id,
)
except ModuleInstallerError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
audit_from_principal(
session,
principal,
@@ -466,7 +684,12 @@ def cancel_module_install_request(
scope="system",
object_type="module_install_request",
object_id=request_id,
details={},
details=audit_operation_context(
request_id=request_id,
outcome=request.get("status"),
trace=request.get("trace") if isinstance(request.get("trace"), dict) else None,
cancelled_by=request.get("cancelled_by"),
),
)
session.commit()
return ModuleInstallerRequestItem.model_validate(request)
@@ -491,7 +714,7 @@ def retry_module_install_request(
requested_by=principal.user.id,
)
except ModuleInstallerError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
audit_from_principal(
session,
principal,
@@ -499,7 +722,13 @@ def retry_module_install_request(
scope="system",
object_type="module_install_request",
object_id=request_id,
details={"new_request_id": request["request_id"]},
details=audit_operation_context(
request_id=request_id,
outcome=request.get("status"),
trace=request.get("trace") if isinstance(request.get("trace"), dict) else None,
retry_of=request.get("retry_of"),
new_request_id=request.get("request_id"),
),
)
session.commit()
return ModuleInstallerRequestItem.model_validate(request)
@@ -511,6 +740,7 @@ def read_module_package_catalog(
):
del principal
result = validate_module_package_catalog()
license_diagnostics = module_license_diagnostics(required_features=_catalog_required_license_features(result))
return ModulePackageCatalogResponse(
modules=[_module_package_catalog_item(item) for item in result["modules"] if isinstance(item, dict)],
configured=bool(result["configured"]),
@@ -518,13 +748,17 @@ def read_module_package_catalog(
path=result["path"] if isinstance(result["path"], str) else None,
source=result["source"] if isinstance(result.get("source"), str) else None,
source_type=result["source_type"] if isinstance(result.get("source_type"), str) else None,
cache_used=bool(result.get("cache_used")),
cache_path=result["cache_path"] if isinstance(result.get("cache_path"), str) else None,
channel=result["channel"] if isinstance(result["channel"], str) else None,
sequence=result["sequence"] if isinstance(result.get("sequence"), int) else None,
generated_at=result["generated_at"] if isinstance(result.get("generated_at"), str) else None,
not_before=result["not_before"] if isinstance(result.get("not_before"), str) else None,
expires_at=result["expires_at"] if isinstance(result.get("expires_at"), str) else None,
signed=bool(result["signed"]),
trusted=bool(result["trusted"]),
key_id=result["key_id"] if isinstance(result["key_id"], str) else None,
license=ModuleLicenseDiagnostics.model_validate(license_diagnostics),
warnings=[str(item) for item in result["warnings"]] if isinstance(result["warnings"], list) else [],
error=result["error"] if isinstance(result["error"], str) else None,
)
@@ -542,7 +776,7 @@ def plan_module_install_from_catalog(
plan = _upsert_install_plan_item(session, item)
record_module_package_catalog_acceptance(validation)
except ModuleManagementError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
audit_from_principal(
session,
principal,
@@ -550,7 +784,16 @@ def plan_module_install_from_catalog(
scope="system",
object_type="module_install_plan",
object_id=module_id,
details={"items": [item.as_dict() for item in plan.items]},
details=audit_operation_context(
module_id=module_id,
outcome="planned",
items=[item.as_dict() for item in plan.items],
catalog_validation={
"valid": validation.get("valid"),
"channel": validation.get("channel"),
"key_id": validation.get("key_id"),
},
),
)
session.commit()
return _module_install_plan_response(session, request, notes=[f"Catalog install entry planned for {module_id}."])
@@ -569,14 +812,14 @@ def plan_module_uninstall(
configured_enabled = configured_enabled_modules(core_settings.enabled_modules)
desired_enabled = set(saved_desired_enabled_modules(session, configured_enabled))
if module_id in PROTECTED_MODULES:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Protected platform modules cannot be uninstalled.")
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Protected platform modules cannot be uninstalled.")
if module_id in current_enabled or module_id in desired_enabled:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Disable the module before planning package uninstall.")
try:
item = module_uninstall_plan_item(module_id, lifecycle.available_modules)
plan = _upsert_install_plan_item(session, item)
except ModuleManagementError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
audit_from_principal(
session,
principal,
@@ -584,7 +827,11 @@ def plan_module_uninstall(
scope="system",
object_type="module_install_plan",
object_id=module_id,
details={"items": [item.as_dict() for item in plan.items]},
details=audit_operation_context(
module_id=module_id,
outcome="planned",
items=[item.as_dict() for item in plan.items],
),
)
session.commit()
return _module_install_plan_response(session, request, notes=[f"Package uninstall planned for {module_id}."])
@@ -599,14 +846,28 @@ def update_system_modules(
):
lifecycle = _request_lifecycle(request)
available = lifecycle.available_modules
before_value = {"enabled_modules": list(saved_desired_enabled_modules(session, configured_enabled_modules(core_settings.enabled_modules)))}
apply_value = {"enabled_modules": list(payload.enabled_modules)}
try:
approval = ensure_configuration_change_allowed(
session,
key="module_management.desired_enabled",
value=apply_value,
actor_user_id=principal.user.id,
actor_scopes=tuple(principal.scopes),
change_request_id=payload.change_request_id,
target={"scope": "system"},
)
except ConfigurationControlError as exc:
raise _configuration_control_http_error(exc) from exc
try:
plan = plan_desired_enabled_modules(payload.enabled_modules, available)
except ModuleManagementError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
try:
result = lifecycle.apply_enabled_modules(plan.enabled_modules, protected_modules=PROTECTED_MODULES)
except Exception as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
desired = save_desired_enabled_modules(session, result.enabled_modules)
audit_from_principal(
session,
@@ -615,13 +876,24 @@ def update_system_modules(
scope="system",
object_type="module_state",
object_id="global",
details={
"desired_enabled": list(desired),
"added_dependencies": list(plan.added_dependencies),
"activated": list(result.activated_modules),
"deactivated": list(result.deactivated_modules),
"mounted": list(result.mounted_modules),
},
details=audit_operation_context(
outcome="applied",
desired_enabled=list(desired),
added_dependencies=list(plan.added_dependencies),
activated=list(result.activated_modules),
deactivated=list(result.deactivated_modules),
mounted=list(result.mounted_modules),
),
)
record_configuration_change_applied(
session,
key="module_management.desired_enabled",
before_value=before_value,
after_value={"enabled_modules": list(desired)},
actor_user_id=principal.user.id,
approval=approval,
target={"scope": "system"},
audit_event="module_management.updated",
)
session.commit()
notes = ["Module state saved and applied to the running server."]
@@ -641,10 +913,24 @@ def update_module_install_plan(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:settings:write")),
):
before_value = {"items": [item.as_dict() for item in saved_module_install_plan(session).items]}
apply_value = {"items": [item.model_dump() for item in payload.items]}
try:
approval = ensure_configuration_change_allowed(
session,
key="module_management.install_plan",
value=apply_value,
actor_user_id=principal.user.id,
actor_scopes=tuple(principal.scopes),
change_request_id=payload.change_request_id,
target={"scope": "system"},
)
except ConfigurationControlError as exc:
raise _configuration_control_http_error(exc) from exc
try:
plan = save_module_install_plan(session, [item.model_dump() for item in payload.items])
except ModuleManagementError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
audit_from_principal(
session,
principal,
@@ -652,7 +938,21 @@ def update_module_install_plan(
scope="system",
object_type="module_install_plan",
object_id="global",
details={"items": [item.as_dict() for item in plan.items]},
details=audit_operation_context(
outcome="saved",
items=[item.as_dict() for item in plan.items],
item_count=len(plan.items),
),
)
record_configuration_change_applied(
session,
key="module_management.install_plan",
before_value=before_value,
after_value={"items": [item.as_dict() for item in plan.items]},
actor_user_id=principal.user.id,
approval=approval,
target={"scope": "system"},
audit_event="module_install.requested",
)
session.commit()
return _module_install_plan_response(session, request, notes=["Module package install plan saved."])
@@ -672,7 +972,11 @@ def clear_module_install_plan(
scope="system",
object_type="module_install_plan",
object_id="global",
details={"items": [item.as_dict() for item in plan.items]},
details=audit_operation_context(
outcome="cleared",
items=[item.as_dict() for item in plan.items],
item_count=len(plan.items),
),
)
session.commit()
return _module_install_plan_response(session, request, notes=["Module package install plan cleared."])
@@ -683,18 +987,59 @@ def read_system_settings(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
):
item = get_system_settings(session)
get_system_settings(session)
session.commit()
policy = privacy_policy_from_settings(item)
maintenance_mode = saved_maintenance_mode(session)
return SystemSettingsItem(
default_locale=item.default_locale,
allow_tenant_custom_groups=item.allow_tenant_custom_groups,
allow_tenant_custom_roles=item.allow_tenant_custom_roles,
allow_tenant_api_keys=item.allow_tenant_api_keys,
privacy_retention_policy=PrivacyRetentionPolicyItem.model_validate(policy.model_dump(mode="json")),
maintenance_mode=MaintenanceModeItem.model_validate(maintenance_mode.as_dict()),
settings=item.settings or {},
return _system_settings_item(session)
def _full_system_settings_delta_response(session: Session) -> SystemSettingsDeltaResponse:
item = _system_settings_item(session)
return SystemSettingsDeltaResponse(
item=item,
sections=_system_settings_sections(item),
changed_sections=list(SYSTEM_SETTINGS_SECTIONS),
deleted=[],
watermark=_admin_delta_watermark(session, (SYSTEM_SETTINGS_COLLECTION,)),
has_more=False,
full=True,
)
@router.get("/system/settings/delta", response_model=SystemSettingsDeltaResponse)
def read_system_settings_delta(
since: str | None = None,
limit: int = Query(default=100, ge=1, le=500),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
):
del principal
get_system_settings(session)
session.commit()
if since is None:
return _full_system_settings_delta_response(session)
entries, has_more = _admin_delta_entries(session, collections=(SYSTEM_SETTINGS_COLLECTION,), since=since, limit=limit)
if entries is None:
return _full_system_settings_delta_response(session)
changed_sections = [
section
for section in SYSTEM_SETTINGS_SECTIONS
if section in {entry.resource_id for entry in entries if entry.resource_type == SYSTEM_SETTINGS_RESOURCE}
]
item = _system_settings_item(session)
sections = _system_settings_sections(item)
return SystemSettingsDeltaResponse(
item=None,
sections={section: sections[section] for section in changed_sections},
changed_sections=changed_sections,
deleted=[],
watermark=_admin_delta_response_watermark(
session,
collections=(SYSTEM_SETTINGS_COLLECTION,),
entries=entries,
has_more=has_more,
),
has_more=has_more,
full=False,
)
@@ -705,10 +1050,45 @@ def write_system_settings(
principal: ApiPrincipal = Depends(require_scope("system:settings:write")),
):
item = get_system_settings(session)
privacy_approval: ConfigurationChangeApproval | None = None
maintenance_approval: ConfigurationChangeApproval | None = None
before_sections = _system_settings_sections(_system_settings_item(session))
before_privacy = privacy_policy_from_settings(item).model_dump(mode="json")
before_maintenance = saved_maintenance_mode(session).as_dict()
if payload.privacy_retention_policy is not None:
privacy_value = payload.privacy_retention_policy.model_dump(mode="json")
try:
privacy_approval = ensure_configuration_change_allowed(
session,
key="privacy_retention_policy",
value=privacy_value,
actor_user_id=principal.user.id,
actor_scopes=tuple(principal.scopes),
change_request_id=payload.change_request_id,
target={"scope": "system"},
)
except ConfigurationControlError as exc:
raise _configuration_control_http_error(exc) from exc
current_i18n = i18n_settings(item.settings)
raw_available = payload.available_languages if "available_languages" in payload.model_fields_set else current_i18n.get("available_languages")
raw_enabled = payload.enabled_language_codes if "enabled_language_codes" in payload.model_fields_set else current_i18n.get("enabled_language_codes", current_i18n.get("enabled_languages"))
available_languages = normalize_language_packages(
[item.model_dump() for item in raw_available] if payload.available_languages is not None else raw_available
)
enabled_language_codes = normalize_enabled_language_codes(
raw_enabled,
available_languages,
default_locale=payload.default_locale,
)
item.default_locale = payload.default_locale.strip() or "en"
item.allow_tenant_custom_groups = payload.allow_tenant_custom_groups
item.allow_tenant_custom_roles = payload.allow_tenant_custom_roles
item.allow_tenant_api_keys = payload.allow_tenant_api_keys
item.settings = update_i18n_settings(
item.settings,
available_languages=available_languages,
enabled_language_codes=enabled_language_codes,
)
if payload.privacy_retention_policy is not None:
set_privacy_policy(item, payload.privacy_retention_policy.model_dump(mode="json"))
if payload.maintenance_mode is not None:
@@ -717,9 +1097,21 @@ def write_system_settings(
enabled=payload.maintenance_mode.enabled,
message=payload.maintenance_mode.message.strip() if payload.maintenance_mode.message else None,
)
try:
maintenance_approval = ensure_configuration_change_allowed(
session,
key="maintenance_mode",
value=next_mode.as_dict(),
actor_user_id=principal.user.id,
actor_scopes=tuple(principal.scopes),
change_request_id=None,
target={"scope": "system"},
)
except ConfigurationControlError as exc:
raise _configuration_control_http_error(exc) from exc
if current.enabled != next_mode.enabled and not has_scope(principal, MAINTENANCE_ACCESS_SCOPE):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Changing maintenance mode requires {MAINTENANCE_ACCESS_SCOPE}.",
)
save_maintenance_mode(session, next_mode)
@@ -728,8 +1120,32 @@ def write_system_settings(
session, principal, action="system_settings.updated", scope="system", object_type="system_settings", object_id=item.id,
details=payload.model_dump(),
)
if payload.privacy_retention_policy is not None:
record_configuration_change_applied(
session,
key="privacy_retention_policy",
before_value=before_privacy,
after_value=privacy_policy_from_settings(item).model_dump(mode="json"),
actor_user_id=principal.user.id,
approval=privacy_approval,
target={"scope": "system"},
audit_event="privacy_retention.policy_updated",
)
if payload.maintenance_mode is not None:
record_configuration_change_applied(
session,
key="maintenance_mode",
before_value=before_maintenance,
after_value=saved_maintenance_mode(session).as_dict(),
actor_user_id=principal.user.id,
approval=maintenance_approval,
target={"scope": "system"},
audit_event="system_settings.updated",
)
after_sections = _system_settings_sections(_system_settings_item(session))
_record_system_settings_section_changes(session, before=before_sections, after=after_sections, principal=principal)
session.commit()
return read_system_settings(session=session, principal=principal)
return _system_settings_item(session)
@router.get("/system/governance-templates", response_model=GovernanceTemplateListResponse)
@@ -746,12 +1162,63 @@ def list_governance_templates(
return GovernanceTemplateListResponse(templates=[_governance_template_item(session, item) for item in items])
def _full_governance_template_delta_response(session: Session) -> GovernanceTemplateListDeltaResponse:
items = session.query(GovernanceTemplate).order_by(GovernanceTemplate.kind.asc(), GovernanceTemplate.name.asc()).all()
return GovernanceTemplateListDeltaResponse(
templates=[_governance_template_item(session, item) for item in items],
deleted=[],
watermark=_admin_delta_watermark(session, (GOVERNANCE_TEMPLATES_COLLECTION,)),
has_more=False,
full=True,
)
@router.get("/system/governance-templates/delta", response_model=GovernanceTemplateListDeltaResponse)
def list_governance_templates_delta(
since: str | None = None,
limit: int = Query(default=100, ge=1, le=500),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:governance:read")),
):
del principal
if since is None:
return _full_governance_template_delta_response(session)
entries, has_more = _admin_delta_entries(session, collections=(GOVERNANCE_TEMPLATES_COLLECTION,), since=since, limit=limit)
if entries is None:
return _full_governance_template_delta_response(session)
changed_ids = [entry.resource_id for entry in entries if entry.resource_id and entry.operation != "deleted"]
items = []
if changed_ids:
items = session.query(GovernanceTemplate).filter(GovernanceTemplate.id.in_(changed_ids)).order_by(GovernanceTemplate.kind.asc(), GovernanceTemplate.name.asc()).all()
visible_template_ids = {item.id for item in items}
return GovernanceTemplateListDeltaResponse(
templates=[_governance_template_item(session, item) for item in items],
deleted=_governance_template_deleted_entries(entries, visible_template_ids),
watermark=_admin_delta_response_watermark(session, collections=(GOVERNANCE_TEMPLATES_COLLECTION,), entries=entries, has_more=has_more),
has_more=has_more,
full=False,
)
@router.post("/system/governance-templates", response_model=GovernanceTemplateItem, status_code=status.HTTP_201_CREATED)
def create_governance_template(
payload: GovernanceTemplateCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:governance:write")),
):
apply_value = payload.model_dump(exclude={"change_request_id"})
try:
approval = ensure_configuration_change_allowed(
session,
key="governance_templates",
value=apply_value,
actor_user_id=principal.user.id,
actor_scopes=tuple(principal.scopes),
change_request_id=payload.change_request_id,
target={"kind": payload.kind, "slug": payload.slug},
)
except ConfigurationControlError as exc:
raise _configuration_control_http_error(exc) from exc
try:
item = create_template(
session,
@@ -774,6 +1241,17 @@ def create_governance_template(
object_id=item.id,
details={"slug": item.slug, "tenant_ids": [assignment.tenant_id for assignment in payload.assignments]},
)
record_configuration_change_applied(
session,
key="governance_templates",
before_value=None,
after_value={**apply_value, "id": item.id},
actor_user_id=principal.user.id,
approval=approval,
target={"kind": item.kind, "id": item.id},
audit_event="governance_template.updated",
)
_record_governance_template_change(session, item=item, operation="created", principal=principal)
session.commit()
return _governance_template_item(session, item)
@@ -788,6 +1266,20 @@ def update_governance_template(
item = session.get(GovernanceTemplate, template_id)
if item is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Governance template not found")
before_value = _governance_template_item(session, item).model_dump(mode="json")
apply_value = payload.model_dump(exclude={"change_request_id"})
try:
approval = ensure_configuration_change_allowed(
session,
key="governance_templates",
value=apply_value,
actor_user_id=principal.user.id,
actor_scopes=tuple(principal.scopes),
change_request_id=payload.change_request_id,
target={"kind": item.kind, "id": item.id},
)
except ConfigurationControlError as exc:
raise _configuration_control_http_error(exc) from exc
try:
update_template(
session,
@@ -809,6 +1301,17 @@ def update_governance_template(
object_id=item.id,
details={"tenant_ids": [assignment.tenant_id for assignment in payload.assignments]},
)
record_configuration_change_applied(
session,
key="governance_templates",
before_value=before_value,
after_value=_governance_template_item(session, item).model_dump(mode="json"),
actor_user_id=principal.user.id,
approval=approval,
target={"kind": item.kind, "id": item.id},
audit_event="governance_template.updated",
)
_record_governance_template_change(session, item=item, operation="updated", principal=principal)
session.commit()
return _governance_template_item(session, item)
@@ -816,6 +1319,7 @@ def update_governance_template(
@router.delete("/system/governance-templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
def remove_governance_template(
template_id: str,
change_request_id: str | None = Query(default=None),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:governance:write")),
):
@@ -823,6 +1327,20 @@ def remove_governance_template(
if item is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Governance template not found")
kind = item.kind
before_value = _governance_template_item(session, item).model_dump(mode="json")
apply_value = {"id": item.id, "kind": item.kind, "delete": True}
try:
approval = ensure_configuration_change_allowed(
session,
key="governance_templates",
value=apply_value,
actor_user_id=principal.user.id,
actor_scopes=tuple(principal.scopes),
change_request_id=change_request_id,
target={"kind": item.kind, "id": item.id},
)
except ConfigurationControlError as exc:
raise _configuration_control_http_error(exc) from exc
try:
delete_template(session, item)
except (AdminConflictError, AdminValidationError) as exc:
@@ -836,5 +1354,16 @@ def remove_governance_template(
object_id=template_id,
details={},
)
record_configuration_change_applied(
session,
key="governance_templates",
before_value=before_value,
after_value=None,
actor_user_id=principal.user.id,
approval=approval,
target={"kind": kind, "id": template_id},
audit_event="governance_template.updated",
)
_record_governance_template_change(session, item=item, operation="deleted", principal=principal)
session.commit()
return None