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")
+69 -1
View File
@@ -3,7 +3,15 @@ from __future__ import annotations
from govoplan_admin.backend.db import models as admin_models # noqa: F401 - populate Admin ORM metadata
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.module_guards import persistent_table_uninstall_guard
from govoplan_core.core.modules import DocumentationTopic, FrontendModule, MigrationSpec, ModuleContext, ModuleManifest
from govoplan_core.core.modules import (
DocumentationTopic,
FrontendModule,
MigrationSpec,
ModuleContext,
ModuleManifest,
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
@@ -16,10 +24,49 @@ def _route_factory(context: ModuleContext):
return router
ADMIN_PERMISSIONS = (
PermissionDefinition(
scope="admin:module:read",
module_id="admin",
resource="module",
action="read",
label="View tenant modules",
description="Inspect module availability, requirements, and effective state for the active tenant.",
category="Administration",
level="tenant",
),
PermissionDefinition(
scope="admin:module:write",
module_id="admin",
resource="module",
action="write",
label="Manage tenant modules",
description="Enable or disable modules for the active tenant within system policy.",
category="Administration",
level="tenant",
),
)
ADMIN_ROLE_TEMPLATES = (
RoleTemplate(
slug="module_admin",
name="Module administrator",
description="Manage the active tenant's module selection within system policy.",
permissions=("admin:module:read", "admin:module:write"),
level="tenant",
managed=True,
protected=False,
),
)
manifest = ModuleManifest(
id="admin",
name="Admin",
version="0.1.8",
permissions=ADMIN_PERMISSIONS,
role_templates=ADMIN_ROLE_TEMPLATES,
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
route_factory=_route_factory,
documentation=(
@@ -39,6 +86,25 @@ manifest = ModuleManifest(
],
},
),
DocumentationTopic(
id="admin.tenant-module-entitlements",
title="Govern modules per tenant",
summary="System administrators set each tenant's module ceiling and forced modules; tenant module administrators choose within that ceiling.",
body=(
"Deployment activation installs and loads module code for the whole instance. Tenant module governance is a separate entitlement layer: system administrators mark modules unavailable, available, or forced for a tenant and may also change that tenant's selection. A tenant module administrator can only enable or disable available modules; forced modules and required dependencies remain effective. Module entitlement never grants permissions, and malformed policy fails closed to protected administration modules. Enabling a capability module such as Encryption only makes its services available; data encryption remains an explicit owning-module policy or migration decision."
),
layer="configured",
documentation_types=("admin",),
audience=("system_admin", "module_admin", "tenant_admin"),
related_modules=("access", "policy", "views", "encryption"),
metadata={
"kind": "workflow",
"help_contexts": [
"admin.system-tenant-modules",
"admin.tenant-modules",
],
},
),
DocumentationTopic(
id="admin.governance-and-module-lifecycle",
title="Govern configuration and module lifecycle",
@@ -96,6 +162,8 @@ manifest = ModuleManifest(
ViewSurface(id="admin.section.system-role-templates", module_id="admin", kind="section", label="Role templates", order=40),
ViewSurface(id="admin.section.system-groups", module_id="admin", kind="section", label="Group templates", order=50),
ViewSurface(id="admin.section.system-modules", module_id="admin", kind="section", label="Modules", order=85),
ViewSurface(id="admin.section.system-tenant-modules", module_id="admin", kind="section", label="Tenant modules", order=86),
ViewSurface(id="admin.section.tenant-modules", module_id="admin", kind="section", label="Modules", order=60),
),
),
migration_spec=MigrationSpec(module_id="admin", metadata=Base.metadata),