Add tenant module entitlement administration

This commit is contained in:
2026-08-04 08:21:49 +02:00
parent b71523e364
commit 60e2676809
10 changed files with 898 additions and 8 deletions
+258 -1
View File
@@ -44,6 +44,14 @@ from govoplan_core.core.module_management import (
saved_desired_enabled_modules,
saved_module_install_plan,
)
from govoplan_core.core.module_entitlements import (
ModuleEntitlementConflict,
ModuleEntitlementError,
module_entitlement_payload,
tenant_module_entitlement_state,
update_system_tenant_module_policy,
update_tenant_module_selection,
)
from govoplan_core.core.module_installer import (
ModuleInstallerError,
cancel_module_installer_request,
@@ -110,6 +118,10 @@ from .schemas import (
ModulePackageCatalogResponse,
ModuleLicenseDiagnostics,
ModuleStateUpdateRequest,
SystemTenantModulePolicyUpdateRequest,
TenantModuleEntitlementResponse,
TenantModuleSelectionUpdateRequest,
TenantModuleTargetListResponse,
PrivacyRetentionPolicyItem,
SystemSettingsDeltaResponse,
SystemSettingsItem,
@@ -699,7 +711,7 @@ def _catalog_required_license_features(result: dict[str, object]) -> list[str]:
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:users:read", "admin:groups:read", "admin:roles:read", "admin:settings:read", "admin:module:read", "admin:module:write",
"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",
)),
@@ -736,6 +748,251 @@ def list_system_modules(
return _module_catalog_response(session, request)
def _tenant_or_404(
session: Session,
tenant_id: str,
*,
for_update: bool = False,
) -> Tenant:
query = session.query(Tenant).filter(Tenant.id == tenant_id)
if for_update:
query = query.populate_existing().with_for_update()
tenant = query.one_or_none()
if tenant is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found",
)
return tenant
def _tenant_module_entitlement_response(
request: Request,
tenant: Tenant,
) -> TenantModuleEntitlementResponse:
lifecycle = _request_lifecycle(request)
available = dict(lifecycle.available_modules)
runtime_active = lifecycle.active_module_ids()
state = tenant_module_entitlement_state(
tenant.settings or {},
available,
runtime_active_modules=runtime_active,
)
return TenantModuleEntitlementResponse.model_validate(
module_entitlement_payload(tenant.id, state)
)
@router.get(
"/system/tenant-module-targets",
response_model=TenantModuleTargetListResponse,
)
def list_tenant_module_targets(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
):
del principal
tenants = session.query(Tenant).order_by(Tenant.name.asc(), Tenant.id.asc()).all()
return TenantModuleTargetListResponse(
tenants=[
{
"id": tenant.id,
"slug": tenant.slug,
"name": tenant.name,
"is_active": tenant.is_active,
}
for tenant in tenants
]
)
@router.get(
"/system/tenants/{tenant_id}/modules",
response_model=TenantModuleEntitlementResponse,
)
def read_system_tenant_modules(
tenant_id: str,
request: Request,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
):
del principal
return _tenant_module_entitlement_response(
request,
_tenant_or_404(session, tenant_id),
)
@router.put(
"/system/tenants/{tenant_id}/modules",
response_model=TenantModuleEntitlementResponse,
)
def update_system_tenant_modules(
tenant_id: str,
request: Request,
payload: SystemTenantModulePolicyUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:settings:write")),
):
tenant = _tenant_or_404(session, tenant_id, for_update=True)
lifecycle = _request_lifecycle(request)
available = dict(lifecycle.available_modules)
runtime_active = lifecycle.active_module_ids()
before = tenant_module_entitlement_state(
tenant.settings or {},
available,
runtime_active_modules=runtime_active,
)
policy_value = {
"available_modules": list(payload.available_modules),
"forced_modules": list(payload.forced_modules),
"enabled_modules": list(payload.enabled_modules),
"expected_revision": payload.expected_revision,
}
try:
approval = ensure_configuration_change_allowed(
session,
key="module_entitlements.system_policy",
value=policy_value,
actor_user_id=principal.user.id,
actor_scopes=tuple(principal.scopes),
change_request_id=payload.change_request_id,
target={"scope": "tenant", "tenant_id": tenant.id},
)
updated_settings, state = update_system_tenant_module_policy(
tenant.settings or {},
available,
available_modules=payload.available_modules,
forced_modules=payload.forced_modules,
enabled_modules=payload.enabled_modules,
expected_revision=payload.expected_revision,
runtime_active_modules=runtime_active,
)
except ConfigurationControlError as exc:
session.rollback()
raise _configuration_control_http_error(exc) from exc
except ModuleEntitlementConflict as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
except ModuleEntitlementError as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
tenant.settings = updated_settings
session.add(tenant)
audit_from_principal(
session,
principal,
action="tenant_modules.system_policy_updated",
scope="system",
object_type="tenant_module_entitlement",
object_id=tenant.id,
details=audit_operation_context(
outcome="saved",
tenant_id=tenant.id,
revision=state.revision,
available_modules=list(state.available_modules),
forced_modules=list(state.forced_modules),
selected_modules=list(state.selected_modules),
effective_modules=list(state.effective_modules),
),
)
record_configuration_change_applied(
session,
key="module_entitlements.system_policy",
before_value=module_entitlement_payload(tenant.id, before),
after_value=module_entitlement_payload(tenant.id, state),
actor_user_id=principal.user.id,
approval=approval,
target={"scope": "tenant", "tenant_id": tenant.id},
audit_event="tenant_modules.system_policy_updated",
)
session.commit()
return TenantModuleEntitlementResponse.model_validate(
module_entitlement_payload(tenant.id, state)
)
@router.get(
"/tenant/modules",
response_model=TenantModuleEntitlementResponse,
)
def read_tenant_modules(
request: Request,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_any_scope(
"admin:module:read",
"admin:module:write",
"system:settings:read",
)
),
):
return _tenant_module_entitlement_response(
request,
_tenant_or_404(session, principal.tenant_id),
)
@router.put(
"/tenant/modules",
response_model=TenantModuleEntitlementResponse,
)
def update_tenant_modules(
request: Request,
payload: TenantModuleSelectionUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_any_scope("admin:module:write", "system:settings:write")
),
):
tenant = _tenant_or_404(session, principal.tenant_id, for_update=True)
lifecycle = _request_lifecycle(request)
available = dict(lifecycle.available_modules)
runtime_active = lifecycle.active_module_ids()
try:
updated_settings, state = update_tenant_module_selection(
tenant.settings or {},
available,
enabled_modules=payload.enabled_modules,
expected_revision=payload.expected_revision,
runtime_active_modules=runtime_active,
)
except ModuleEntitlementConflict as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
except ModuleEntitlementError as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
tenant.settings = updated_settings
session.add(tenant)
audit_from_principal(
session,
principal,
action="tenant_modules.selection_updated",
scope="tenant",
object_type="tenant_module_entitlement",
object_id=tenant.id,
details=audit_operation_context(
outcome="saved",
tenant_id=tenant.id,
revision=state.revision,
selected_modules=list(state.selected_modules),
effective_modules=list(state.effective_modules),
),
)
session.commit()
return TenantModuleEntitlementResponse.model_validate(
module_entitlement_payload(tenant.id, state)
)
@router.get("/system/modules/install-plan", response_model=ModuleInstallPlanResponse)
def read_module_install_plan(
request: Request,
@@ -120,6 +120,61 @@ class ModuleStateUpdateRequest(BaseModel):
change_request_id: str | None = None
class TenantModuleEntitlementItem(BaseModel):
id: str
name: str
dependencies: list[str] = Field(default_factory=list)
runtime_active: bool
availability: Literal["unavailable", "available", "forced"]
selected: bool
effective: bool
forced: bool
derived_dependency: bool
tenant_can_toggle: bool
reason: str | None = None
class TenantModuleEntitlementResponse(BaseModel):
tenant_id: str
revision: int
configured: bool
available_modules: list[str] = Field(default_factory=list)
forced_modules: list[str] = Field(default_factory=list)
selected_modules: list[str] = Field(default_factory=list)
effective_modules: list[str] = Field(default_factory=list)
derived_dependencies: list[str] = Field(default_factory=list)
modules: list[TenantModuleEntitlementItem] = Field(default_factory=list)
diagnostics: list[dict[str, str]] = Field(default_factory=list)
class TenantModuleTargetItem(BaseModel):
id: str
slug: str
name: str
is_active: bool
class TenantModuleTargetListResponse(BaseModel):
tenants: list[TenantModuleTargetItem] = Field(default_factory=list)
class SystemTenantModulePolicyUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
available_modules: list[str] = Field(default_factory=list, max_length=1000)
forced_modules: list[str] = Field(default_factory=list, max_length=1000)
enabled_modules: list[str] = Field(default_factory=list, max_length=1000)
expected_revision: int | None = Field(default=None, ge=0)
change_request_id: str | None = None
class TenantModuleSelectionUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
enabled_modules: list[str] = Field(default_factory=list, max_length=1000)
expected_revision: int | None = Field(default=None, ge=0)
class ModuleInstallPlanItem(BaseModel):
model_config = ConfigDict(extra="forbid")