Add tenant module entitlement administration
This commit is contained in:
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user