Add tenant module entitlement administration
This commit is contained in:
@@ -61,6 +61,25 @@ blocker is shown through Core's actionable blocker pattern with the required
|
|||||||
action, responsible operator or administrator, and destination. Contextual help
|
action, responsible operator or administrator, and destination. Contextual help
|
||||||
uses the stable `admin.module-lifecycle-workflow` documentation topic.
|
uses the stable `admin.module-lifecycle-workflow` documentation topic.
|
||||||
|
|
||||||
|
## Tenant Module Entitlements
|
||||||
|
|
||||||
|
Deployment lifecycle and tenant availability are separate administration
|
||||||
|
workflows. **System > Tenant modules** lets a system administrator select a
|
||||||
|
tenant, mark installed modules unavailable, available, or forced, and set the
|
||||||
|
tenant's current selection. **Tenant > Modules** lets an account with the
|
||||||
|
`admin:module:write` permission change only the available selection. The
|
||||||
|
`module_admin` role template grants the narrow read/write pair for that task.
|
||||||
|
|
||||||
|
Core closes required dependencies, retains protected administration modules,
|
||||||
|
uses an optimistic entitlement revision, and records audit and governed
|
||||||
|
configuration evidence. A selected module that is not globally active remains
|
||||||
|
configured but unavailable at runtime. Module selection never grants module
|
||||||
|
permissions.
|
||||||
|
|
||||||
|
User and group module visibility is configured through Views, where each WebUI
|
||||||
|
module is represented by its root module surface. This keeps tenant operational
|
||||||
|
state distinct from presentation preferences.
|
||||||
|
|
||||||
## Package Surfaces
|
## Package Surfaces
|
||||||
|
|
||||||
The admin UI intentionally exposes two different package concepts:
|
The admin UI intentionally exposes two different package concepts:
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ from govoplan_core.core.module_management import (
|
|||||||
saved_desired_enabled_modules,
|
saved_desired_enabled_modules,
|
||||||
saved_module_install_plan,
|
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 (
|
from govoplan_core.core.module_installer import (
|
||||||
ModuleInstallerError,
|
ModuleInstallerError,
|
||||||
cancel_module_installer_request,
|
cancel_module_installer_request,
|
||||||
@@ -110,6 +118,10 @@ from .schemas import (
|
|||||||
ModulePackageCatalogResponse,
|
ModulePackageCatalogResponse,
|
||||||
ModuleLicenseDiagnostics,
|
ModuleLicenseDiagnostics,
|
||||||
ModuleStateUpdateRequest,
|
ModuleStateUpdateRequest,
|
||||||
|
SystemTenantModulePolicyUpdateRequest,
|
||||||
|
TenantModuleEntitlementResponse,
|
||||||
|
TenantModuleSelectionUpdateRequest,
|
||||||
|
TenantModuleTargetListResponse,
|
||||||
PrivacyRetentionPolicyItem,
|
PrivacyRetentionPolicyItem,
|
||||||
SystemSettingsDeltaResponse,
|
SystemSettingsDeltaResponse,
|
||||||
SystemSettingsItem,
|
SystemSettingsItem,
|
||||||
@@ -699,7 +711,7 @@ def _catalog_required_license_features(result: dict[str, object]) -> list[str]:
|
|||||||
def admin_overview(
|
def admin_overview(
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(require_any_scope(
|
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",
|
"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",
|
"system:settings:read", "system:governance:read", "system:audit:read",
|
||||||
)),
|
)),
|
||||||
@@ -736,6 +748,251 @@ def list_system_modules(
|
|||||||
return _module_catalog_response(session, request)
|
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)
|
@router.get("/system/modules/install-plan", response_model=ModuleInstallPlanResponse)
|
||||||
def read_module_install_plan(
|
def read_module_install_plan(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -120,6 +120,61 @@ class ModuleStateUpdateRequest(BaseModel):
|
|||||||
change_request_id: str | None = None
|
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):
|
class ModuleInstallPlanItem(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|||||||
@@ -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_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.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.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.provider_governance import declared_module_architecture
|
||||||
from govoplan_core.core.views import ViewSurface
|
from govoplan_core.core.views import ViewSurface
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
@@ -16,10 +24,49 @@ def _route_factory(context: ModuleContext):
|
|||||||
return router
|
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(
|
manifest = ModuleManifest(
|
||||||
id="admin",
|
id="admin",
|
||||||
name="Admin",
|
name="Admin",
|
||||||
version="0.1.8",
|
version="0.1.8",
|
||||||
|
permissions=ADMIN_PERMISSIONS,
|
||||||
|
role_templates=ADMIN_ROLE_TEMPLATES,
|
||||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||||
route_factory=_route_factory,
|
route_factory=_route_factory,
|
||||||
documentation=(
|
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(
|
DocumentationTopic(
|
||||||
id="admin.governance-and-module-lifecycle",
|
id="admin.governance-and-module-lifecycle",
|
||||||
title="Govern configuration 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-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-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-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),
|
migration_spec=MigrationSpec(module_id="admin", metadata=Base.metadata),
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ class InterfaceDocumentationContractTests(unittest.TestCase):
|
|||||||
"admin.module-lifecycle.plan",
|
"admin.module-lifecycle.plan",
|
||||||
"admin.module-lifecycle.evidence",
|
"admin.module-lifecycle.evidence",
|
||||||
},
|
},
|
||||||
|
"admin.tenant-module-entitlements": {
|
||||||
|
"admin.system-tenant-modules",
|
||||||
|
"admin.tenant-modules",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for topic_id, contexts in expected.items():
|
for topic_id, contexts in expected.items():
|
||||||
self.assertIn(topic_id, topics)
|
self.assertIn(topic_id, topics)
|
||||||
@@ -45,9 +49,18 @@ class InterfaceDocumentationContractTests(unittest.TestCase):
|
|||||||
"admin.section.system-role-templates",
|
"admin.section.system-role-templates",
|
||||||
"admin.section.system-groups",
|
"admin.section.system-groups",
|
||||||
"admin.section.system-modules",
|
"admin.section.system-modules",
|
||||||
|
"admin.section.system-tenant-modules",
|
||||||
|
"admin.section.tenant-modules",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_module_administrator_has_only_tenant_module_permissions(self) -> None:
|
||||||
|
permissions = {item.scope for item in manifest.permissions}
|
||||||
|
template = next(item for item in manifest.role_templates if item.slug == "module_admin")
|
||||||
|
|
||||||
|
self.assertEqual({"admin:module:read", "admin:module:write"}, permissions)
|
||||||
|
self.assertEqual(tuple(sorted(permissions)), tuple(sorted(template.permissions)))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -186,6 +186,42 @@ export type ModuleCatalogResponse = {
|
|||||||
notes: string[];
|
notes: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TenantModuleAvailability = "unavailable" | "available" | "forced";
|
||||||
|
|
||||||
|
export type TenantModuleEntitlementItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
dependencies: string[];
|
||||||
|
runtime_active: boolean;
|
||||||
|
availability: TenantModuleAvailability;
|
||||||
|
selected: boolean;
|
||||||
|
effective: boolean;
|
||||||
|
forced: boolean;
|
||||||
|
derived_dependency: boolean;
|
||||||
|
tenant_can_toggle: boolean;
|
||||||
|
reason?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TenantModuleEntitlementResponse = {
|
||||||
|
tenant_id: string;
|
||||||
|
revision: number;
|
||||||
|
configured: boolean;
|
||||||
|
available_modules: string[];
|
||||||
|
forced_modules: string[];
|
||||||
|
selected_modules: string[];
|
||||||
|
effective_modules: string[];
|
||||||
|
derived_dependencies: string[];
|
||||||
|
modules: TenantModuleEntitlementItem[];
|
||||||
|
diagnostics: Array<{ code: string; message: string; severity: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TenantModuleTarget = {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
is_active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type ModuleInstallPlanItem = {
|
export type ModuleInstallPlanItem = {
|
||||||
module_id: string;
|
module_id: string;
|
||||||
action: "install" | "update" | "uninstall";
|
action: "install" | "update" | "uninstall";
|
||||||
@@ -504,6 +540,46 @@ export function updateModuleState(settings: ApiSettings, enabledModules: string[
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchSystemTenantModules(settings: ApiSettings, tenantId: string): Promise<TenantModuleEntitlementResponse> {
|
||||||
|
return apiFetch(settings, `/api/v1/admin/system/tenants/${encodeURIComponent(tenantId)}/modules`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTenantModuleTargets(settings: ApiSettings): Promise<TenantModuleTarget[]> {
|
||||||
|
const response = await apiFetch<{ tenants: TenantModuleTarget[] }>(settings, "/api/v1/admin/system/tenant-module-targets");
|
||||||
|
return response.tenants;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSystemTenantModules(
|
||||||
|
settings: ApiSettings,
|
||||||
|
tenantId: string,
|
||||||
|
payload: {
|
||||||
|
available_modules: string[];
|
||||||
|
forced_modules: string[];
|
||||||
|
enabled_modules: string[];
|
||||||
|
expected_revision: number;
|
||||||
|
change_request_id?: string | null;
|
||||||
|
}
|
||||||
|
): Promise<TenantModuleEntitlementResponse> {
|
||||||
|
return apiFetch(settings, `/api/v1/admin/system/tenants/${encodeURIComponent(tenantId)}/modules`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchTenantModules(settings: ApiSettings): Promise<TenantModuleEntitlementResponse> {
|
||||||
|
return apiFetch(settings, "/api/v1/admin/tenant/modules");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTenantModules(
|
||||||
|
settings: ApiSettings,
|
||||||
|
payload: { enabled_modules: string[]; expected_revision: number }
|
||||||
|
): Promise<TenantModuleEntitlementResponse> {
|
||||||
|
return apiFetch(settings, "/api/v1/admin/tenant/modules", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchModuleInstallPlan(settings: ApiSettings): Promise<ModuleInstallPlanResponse> {
|
export function fetchModuleInstallPlan(settings: ApiSettings): Promise<ModuleInstallPlanResponse> {
|
||||||
return apiFetch(settings, "/api/v1/admin/system/modules/install-plan");
|
return apiFetch(settings, "/api/v1/admin/system/modules/install-plan");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio
|
|||||||
{hasAnySection(availableSections, platformSectionIds) && <Card title={ADMIN_INTERFACE_I18N.administrationHeading}>
|
{hasAnySection(availableSections, platformSectionIds) && <Card title={ADMIN_INTERFACE_I18N.administrationHeading}>
|
||||||
<div className="admin-overview-grid">
|
<div className="admin-overview-grid">
|
||||||
{availableSections.has("system-modules") && <AreaLink title="i18n:govoplan-admin.modules.04e9462c" text="i18n:govoplan-admin.installed_modules_runtime_state_and_startup_stat.38dd7028" onClick={() => onSelect("system-modules")} />}
|
{availableSections.has("system-modules") && <AreaLink title="i18n:govoplan-admin.modules.04e9462c" text="i18n:govoplan-admin.installed_modules_runtime_state_and_startup_stat.38dd7028" onClick={() => onSelect("system-modules")} />}
|
||||||
|
{availableSections.has("system-tenant-modules") && <AreaLink title="Tenant modules" text="Set per-tenant availability, forced modules, and current selection." onClick={() => onSelect("system-tenant-modules")} />}
|
||||||
{availableSections.has("system-configuration-packages") && <AreaLink title="i18n:govoplan-admin.configuration_packages.eb2f05f1" text="i18n:govoplan-admin.preflight_approve_apply_and_export_configuration.276bd0f8" onClick={() => onSelect("system-configuration-packages")} />}
|
{availableSections.has("system-configuration-packages") && <AreaLink title="i18n:govoplan-admin.configuration_packages.eb2f05f1" text="i18n:govoplan-admin.preflight_approve_apply_and_export_configuration.276bd0f8" onClick={() => onSelect("system-configuration-packages")} />}
|
||||||
{availableSections.has("system-settings") && <AreaLink title="i18n:govoplan-admin.maintenance.94de303b" text="i18n:govoplan-admin.instance_defaults_and_tenant_governance_capabili.99d6b2fa" onClick={() => onSelect("system-settings")} />}
|
{availableSections.has("system-settings") && <AreaLink title="i18n:govoplan-admin.maintenance.94de303b" text="i18n:govoplan-admin.instance_defaults_and_tenant_governance_capabili.99d6b2fa" onClick={() => onSelect("system-settings")} />}
|
||||||
{availableSections.has("system-configuration-changes") && <AreaLink title="i18n:govoplan-admin.changes.8aa57de6" text="i18n:govoplan-admin.configuration_requests_approvals_and_version_his.19f37335" onClick={() => onSelect("system-configuration-changes")} />}
|
{availableSections.has("system-configuration-changes") && <AreaLink title="i18n:govoplan-admin.changes.8aa57de6" text="i18n:govoplan-admin.configuration_requests_approvals_and_version_his.19f37335" onClick={() => onSelect("system-configuration-changes")} />}
|
||||||
@@ -61,6 +62,7 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio
|
|||||||
{availableSections.has("system-file-connectors") && <AreaLink title="i18n:govoplan-admin.file_connections.1e362326" text="i18n:govoplan-admin.reusable_file_server_connections_credentials_and.8e5c7d43" onClick={() => onSelect("system-file-connectors")} />}
|
{availableSections.has("system-file-connectors") && <AreaLink title="i18n:govoplan-admin.file_connections.1e362326" text="i18n:govoplan-admin.reusable_file_server_connections_credentials_and.8e5c7d43" onClick={() => onSelect("system-file-connectors")} />}
|
||||||
{availableSections.has("system-mail-servers") && <AreaLink title="i18n:govoplan-admin.mail_servers.d627326a" text="i18n:govoplan-admin.reusable_encrypted_smtp_imap_profiles_and_mail_p.31f150d1" onClick={() => onSelect("system-mail-servers")} />}
|
{availableSections.has("system-mail-servers") && <AreaLink title="i18n:govoplan-admin.mail_servers.d627326a" text="i18n:govoplan-admin.reusable_encrypted_smtp_imap_profiles_and_mail_p.31f150d1" onClick={() => onSelect("system-mail-servers")} />}
|
||||||
{availableSections.has("system-retention") && <AreaLink title="i18n:govoplan-admin.retention.c7199d9e" text="i18n:govoplan-admin.instance_privacy_retention_policy_and_lower_leve.b8d14069" onClick={() => onSelect("system-retention")} />}
|
{availableSections.has("system-retention") && <AreaLink title="i18n:govoplan-admin.retention.c7199d9e" text="i18n:govoplan-admin.instance_privacy_retention_policy_and_lower_leve.b8d14069" onClick={() => onSelect("system-retention")} />}
|
||||||
|
{availableSections.has("system-view-policy") && <AreaLink title="View policy" text="Limit View actions, definitions, and surfaces across the instance." onClick={() => onSelect("system-view-policy")} />}
|
||||||
</div>
|
</div>
|
||||||
</Card>}
|
</Card>}
|
||||||
|
|
||||||
@@ -81,6 +83,8 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio
|
|||||||
{availableSections.has("tenant-mail-servers") && <AreaLink title="i18n:govoplan-admin.mail_servers.d627326a" text="i18n:govoplan-admin.reusable_encrypted_smtp_imap_profiles_and_mail_p.31f150d1" onClick={() => onSelect("tenant-mail-servers")} />}
|
{availableSections.has("tenant-mail-servers") && <AreaLink title="i18n:govoplan-admin.mail_servers.d627326a" text="i18n:govoplan-admin.reusable_encrypted_smtp_imap_profiles_and_mail_p.31f150d1" onClick={() => onSelect("tenant-mail-servers")} />}
|
||||||
{availableSections.has("tenant-api-keys") && <AreaLink title="i18n:govoplan-admin.api_keys.94fcf3c2" text="i18n:govoplan-admin.scoped_automation_credentials_capped_by_owner_pe.b3e20e54" onClick={() => onSelect("tenant-api-keys")} />}
|
{availableSections.has("tenant-api-keys") && <AreaLink title="i18n:govoplan-admin.api_keys.94fcf3c2" text="i18n:govoplan-admin.scoped_automation_credentials_capped_by_owner_pe.b3e20e54" onClick={() => onSelect("tenant-api-keys")} />}
|
||||||
{availableSections.has("tenant-retention") && <AreaLink title="i18n:govoplan-admin.retention.c7199d9e" text="i18n:govoplan-admin.tenant_level_privacy_retention_limits_inherited_.48f4989d" onClick={() => onSelect("tenant-retention")} />}
|
{availableSections.has("tenant-retention") && <AreaLink title="i18n:govoplan-admin.retention.c7199d9e" text="i18n:govoplan-admin.tenant_level_privacy_retention_limits_inherited_.48f4989d" onClick={() => onSelect("tenant-retention")} />}
|
||||||
|
{availableSections.has("tenant-modules") && <AreaLink title="Modules" text="Choose modules made available to this tenant by system policy." onClick={() => onSelect("tenant-modules")} />}
|
||||||
|
{availableSections.has("tenant-view-policy") && <AreaLink title="View policy" text="Narrow inherited View actions and available surfaces for this tenant." onClick={() => onSelect("tenant-view-policy")} />}
|
||||||
{availableSections.has("tenant-settings") && <AreaLink title="i18n:govoplan-admin.general.9239ee2c" text="i18n:govoplan-admin.tenant_locale_and_tenant_specific_settings.ac49c83b" onClick={() => onSelect("tenant-settings")} />}
|
{availableSections.has("tenant-settings") && <AreaLink title="i18n:govoplan-admin.general.9239ee2c" text="i18n:govoplan-admin.tenant_locale_and_tenant_specific_settings.ac49c83b" onClick={() => onSelect("tenant-settings")} />}
|
||||||
{availableSections.has("tenant-audit") && <AreaLink title="i18n:govoplan-admin.audit.fa1703dd" text="i18n:govoplan-admin.tenant_level_administrative_history_only.55495c3c" onClick={() => onSelect("tenant-audit")} />}
|
{availableSections.has("tenant-audit") && <AreaLink title="i18n:govoplan-admin.audit.fa1703dd" text="i18n:govoplan-admin.tenant_level_administrative_history_only.55495c3c" onClick={() => onSelect("tenant-audit")} />}
|
||||||
</div>
|
</div>
|
||||||
@@ -91,6 +95,7 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio
|
|||||||
{availableSections.has("tenant-group-file-connectors") && <AreaLink title="i18n:govoplan-admin.file_connections.1e362326" text="i18n:govoplan-admin.group_file_connector_policy_limits" onClick={() => onSelect("tenant-group-file-connectors")} />}
|
{availableSections.has("tenant-group-file-connectors") && <AreaLink title="i18n:govoplan-admin.file_connections.1e362326" text="i18n:govoplan-admin.group_file_connector_policy_limits" onClick={() => onSelect("tenant-group-file-connectors")} />}
|
||||||
{availableSections.has("tenant-group-mail-servers") && <AreaLink title="i18n:govoplan-admin.mail_servers.d627326a" text="i18n:govoplan-admin.group_mail_server_policy_limits" onClick={() => onSelect("tenant-group-mail-servers")} />}
|
{availableSections.has("tenant-group-mail-servers") && <AreaLink title="i18n:govoplan-admin.mail_servers.d627326a" text="i18n:govoplan-admin.group_mail_server_policy_limits" onClick={() => onSelect("tenant-group-mail-servers")} />}
|
||||||
{availableSections.has("tenant-group-retention") && <AreaLink title="i18n:govoplan-admin.retention.c7199d9e" text="i18n:govoplan-admin.group_retention_policy_limits" onClick={() => onSelect("tenant-group-retention")} />}
|
{availableSections.has("tenant-group-retention") && <AreaLink title="i18n:govoplan-admin.retention.c7199d9e" text="i18n:govoplan-admin.group_retention_policy_limits" onClick={() => onSelect("tenant-group-retention")} />}
|
||||||
|
{availableSections.has("group-view-policy") && <AreaLink title="View policy" text="Set View limits for a selected group." onClick={() => onSelect("group-view-policy")} />}
|
||||||
</div>
|
</div>
|
||||||
</Card>}
|
</Card>}
|
||||||
|
|
||||||
@@ -99,6 +104,7 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio
|
|||||||
{availableSections.has("tenant-user-file-connectors") && <AreaLink title="i18n:govoplan-admin.file_connections.1e362326" text="i18n:govoplan-admin.user_file_connector_policy_limits" onClick={() => onSelect("tenant-user-file-connectors")} />}
|
{availableSections.has("tenant-user-file-connectors") && <AreaLink title="i18n:govoplan-admin.file_connections.1e362326" text="i18n:govoplan-admin.user_file_connector_policy_limits" onClick={() => onSelect("tenant-user-file-connectors")} />}
|
||||||
{availableSections.has("tenant-user-mail-servers") && <AreaLink title="i18n:govoplan-admin.mail_servers.d627326a" text="i18n:govoplan-admin.user_mail_server_policy_limits" onClick={() => onSelect("tenant-user-mail-servers")} />}
|
{availableSections.has("tenant-user-mail-servers") && <AreaLink title="i18n:govoplan-admin.mail_servers.d627326a" text="i18n:govoplan-admin.user_mail_server_policy_limits" onClick={() => onSelect("tenant-user-mail-servers")} />}
|
||||||
{availableSections.has("tenant-user-retention") && <AreaLink title="i18n:govoplan-admin.retention.c7199d9e" text="i18n:govoplan-admin.user_retention_policy_limits" onClick={() => onSelect("tenant-user-retention")} />}
|
{availableSections.has("tenant-user-retention") && <AreaLink title="i18n:govoplan-admin.retention.c7199d9e" text="i18n:govoplan-admin.user_retention_policy_limits" onClick={() => onSelect("tenant-user-retention")} />}
|
||||||
|
{availableSections.has("user-view-policy") && <AreaLink title="View policy" text="Set View limits for a selected user." onClick={() => onSelect("user-view-policy")} />}
|
||||||
</div>
|
</div>
|
||||||
</Card>}
|
</Card>}
|
||||||
</>}
|
</>}
|
||||||
@@ -106,11 +112,11 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const platformSectionIds = ["system-modules", "system-configuration-packages", "system-settings", "system-configuration-changes", "system-audit"];
|
const platformSectionIds = ["system-modules", "system-tenant-modules", "system-configuration-packages", "system-settings", "system-configuration-changes", "system-audit"];
|
||||||
const globalSectionIds = ["system-tenants", "system-roles", "system-role-templates", "system-groups", "system-users", "system-file-connectors", "system-mail-servers", "system-retention"];
|
const globalSectionIds = ["system-tenants", "system-roles", "system-role-templates", "system-groups", "system-users", "system-file-connectors", "system-mail-servers", "system-retention", "system-view-policy"];
|
||||||
const tenantSectionIds = ["tenant-roles", "tenant-groups", "tenant-users", "tenant-file-connectors", "tenant-mail-servers", "tenant-api-keys", "tenant-retention", "tenant-settings", "tenant-audit"];
|
const tenantSectionIds = ["tenant-roles", "tenant-groups", "tenant-users", "tenant-file-connectors", "tenant-mail-servers", "tenant-api-keys", "tenant-retention", "tenant-view-policy", "tenant-modules", "tenant-settings", "tenant-audit"];
|
||||||
const groupSectionIds = ["tenant-group-file-connectors", "tenant-group-mail-servers", "tenant-group-retention"];
|
const groupSectionIds = ["tenant-group-file-connectors", "tenant-group-mail-servers", "tenant-group-retention", "group-view-policy"];
|
||||||
const userSectionIds = ["tenant-user-file-connectors", "tenant-user-mail-servers", "tenant-user-retention"];
|
const userSectionIds = ["tenant-user-file-connectors", "tenant-user-mail-servers", "tenant-user-retention", "user-view-policy"];
|
||||||
|
|
||||||
function hasAnySection(sections: ReadonlySet<string>, candidates: readonly string[]): boolean {
|
function hasAnySection(sections: ReadonlySet<string>, candidates: readonly string[]): boolean {
|
||||||
return candidates.some((section) => sections.has(section));
|
return candidates.some((section) => sections.has(section));
|
||||||
|
|||||||
@@ -0,0 +1,362 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
AdminPageLayout,
|
||||||
|
adminErrorMessage,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
dispatchPlatformModulesChanged,
|
||||||
|
DismissibleAlert,
|
||||||
|
DocumentationHelpLink,
|
||||||
|
FormField,
|
||||||
|
MetricCard,
|
||||||
|
SearchableSelect,
|
||||||
|
StatusBadge,
|
||||||
|
ToggleSwitch,
|
||||||
|
useUnsavedDraftGuard,
|
||||||
|
type ApiSettings,
|
||||||
|
type SearchableSelectOption
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import { RefreshCw, Save, Undo2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
fetchSystemTenantModules,
|
||||||
|
fetchTenantModuleTargets,
|
||||||
|
fetchTenantModules,
|
||||||
|
updateSystemTenantModules,
|
||||||
|
updateTenantModules,
|
||||||
|
type TenantModuleAvailability,
|
||||||
|
type TenantModuleEntitlementResponse,
|
||||||
|
type TenantModuleTarget
|
||||||
|
} from "../../api/admin";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
settings: ApiSettings;
|
||||||
|
scope: "system" | "tenant";
|
||||||
|
canWrite: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Draft = {
|
||||||
|
available: Set<string>;
|
||||||
|
forced: Set<string>;
|
||||||
|
enabled: Set<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DOCUMENTATION = {
|
||||||
|
contextId: "admin.tenant-modules",
|
||||||
|
documentationType: "admin" as const
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TenantModuleManagementPanel({ settings, scope, canWrite }: Props) {
|
||||||
|
const [targets, setTargets] = useState<TenantModuleTarget[]>([]);
|
||||||
|
const [targetId, setTargetId] = useState("");
|
||||||
|
const [state, setState] = useState<TenantModuleEntitlementResponse | null>(null);
|
||||||
|
const [draft, setDraft] = useState<Draft | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState("");
|
||||||
|
|
||||||
|
const targetOptions = useMemo<SearchableSelectOption[]>(() => targets.map((target) => ({
|
||||||
|
value: target.id,
|
||||||
|
label: target.name,
|
||||||
|
description: `${target.slug}${target.is_active ? "" : " - inactive"}`,
|
||||||
|
searchText: `${target.name} ${target.slug}`
|
||||||
|
})), [targets]);
|
||||||
|
|
||||||
|
const dirty = Boolean(state && draft && (
|
||||||
|
!sameSet(draft.available, new Set(state.available_modules))
|
||||||
|
|| !sameSet(draft.forced, new Set(state.forced_modules))
|
||||||
|
|| !sameSet(draft.enabled, new Set(state.selected_modules))
|
||||||
|
));
|
||||||
|
|
||||||
|
useUnsavedDraftGuard({
|
||||||
|
dirty,
|
||||||
|
onSave: save,
|
||||||
|
onDiscard: discard
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void initialize();
|
||||||
|
}, [scope, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
||||||
|
|
||||||
|
async function initialize() {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
try {
|
||||||
|
if (scope === "system") {
|
||||||
|
const loadedTargets = await fetchTenantModuleTargets(settings);
|
||||||
|
setTargets(loadedTargets);
|
||||||
|
const nextTarget = loadedTargets.some((item) => item.id === targetId)
|
||||||
|
? targetId
|
||||||
|
: loadedTargets[0]?.id ?? "";
|
||||||
|
setTargetId(nextTarget);
|
||||||
|
if (nextTarget) {
|
||||||
|
await load(nextTarget, false);
|
||||||
|
} else {
|
||||||
|
setState(null);
|
||||||
|
setDraft(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setTargets([]);
|
||||||
|
setTargetId("");
|
||||||
|
await load(undefined, false);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
setState(null);
|
||||||
|
setDraft(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(nextTargetId = targetId, manageLoading = true) {
|
||||||
|
if (scope === "system" && !nextTargetId) return;
|
||||||
|
if (manageLoading) setLoading(true);
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
try {
|
||||||
|
const loaded = scope === "system"
|
||||||
|
? await fetchSystemTenantModules(settings, nextTargetId)
|
||||||
|
: await fetchTenantModules(settings);
|
||||||
|
setState(loaded);
|
||||||
|
setDraft(draftFromState(loaded));
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
if (manageLoading) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectTarget(nextTargetId: string) {
|
||||||
|
if (!nextTargetId || nextTargetId === targetId) return;
|
||||||
|
setTargetId(nextTargetId);
|
||||||
|
await load(nextTargetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function discard() {
|
||||||
|
if (state) setDraft(draftFromState(state));
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(): Promise<boolean> {
|
||||||
|
if (!state || !draft || !dirty) return true;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
try {
|
||||||
|
const loaded = scope === "system"
|
||||||
|
? await updateSystemTenantModules(settings, targetId, {
|
||||||
|
available_modules: sorted(draft.available),
|
||||||
|
forced_modules: sorted(draft.forced),
|
||||||
|
enabled_modules: sorted(draft.enabled),
|
||||||
|
expected_revision: state.revision
|
||||||
|
})
|
||||||
|
: await updateTenantModules(settings, {
|
||||||
|
enabled_modules: sorted(draft.enabled),
|
||||||
|
expected_revision: state.revision
|
||||||
|
});
|
||||||
|
setState(loaded);
|
||||||
|
setDraft(draftFromState(loaded));
|
||||||
|
setSuccess("Tenant module selection saved.");
|
||||||
|
dispatchPlatformModulesChanged();
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAvailability(moduleId: string, availability: TenantModuleAvailability) {
|
||||||
|
setDraft((current) => {
|
||||||
|
if (!current) return current;
|
||||||
|
const next = cloneDraft(current);
|
||||||
|
if (availability === "unavailable") {
|
||||||
|
next.available.delete(moduleId);
|
||||||
|
next.forced.delete(moduleId);
|
||||||
|
next.enabled.delete(moduleId);
|
||||||
|
} else {
|
||||||
|
next.available.add(moduleId);
|
||||||
|
if (availability === "forced") next.forced.add(moduleId);
|
||||||
|
else next.forced.delete(moduleId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setEnabled(moduleId: string, enabled: boolean) {
|
||||||
|
setDraft((current) => {
|
||||||
|
if (!current) return current;
|
||||||
|
const next = cloneDraft(current);
|
||||||
|
if (enabled) next.enabled.add(moduleId);
|
||||||
|
else next.enabled.delete(moduleId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = scope === "system"
|
||||||
|
? {
|
||||||
|
title: "Tenant modules",
|
||||||
|
description: "Set a tenant's module ceiling, forced modules, and current selection."
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
title: "Modules",
|
||||||
|
description: "Enable or disable modules made available to this tenant by system policy."
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminPageLayout
|
||||||
|
title={labels.title}
|
||||||
|
description={labels.description}
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
success={success}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
title="Reload saved module policy"
|
||||||
|
aria-label="Reload saved module policy"
|
||||||
|
onClick={() => void load()}
|
||||||
|
disabled={loading || busy || (scope === "system" && !targetId)}
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
</Button>
|
||||||
|
<Button onClick={discard} disabled={!dirty || busy}>
|
||||||
|
<Undo2 size={16} /> Discard
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={() => void save()} disabled={!canWrite || !dirty || busy}>
|
||||||
|
<Save size={16} /> {busy ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
<DocumentationHelpLink reference={DOCUMENTATION} label="Open module governance documentation" />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{scope === "system" && (
|
||||||
|
<FormField label="Tenant" documentation={DOCUMENTATION}>
|
||||||
|
<SearchableSelect
|
||||||
|
value={targetId}
|
||||||
|
options={targetOptions}
|
||||||
|
onChange={(value) => void selectTarget(value)}
|
||||||
|
placeholder="Select tenant"
|
||||||
|
searchPlaceholder="Search tenants..."
|
||||||
|
disabled={loading || busy || targetOptions.length === 0}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state && draft && (
|
||||||
|
<>
|
||||||
|
<div className="metric-grid module-management-metrics">
|
||||||
|
<MetricCard label="Available" value={draft.available.size} tone="info" />
|
||||||
|
<MetricCard label="Forced" value={draft.forced.size} tone="warning" />
|
||||||
|
<MetricCard label="Selected" value={draft.enabled.size} />
|
||||||
|
<MetricCard label="Effective now" value={state.effective_modules.length} tone="good" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state.diagnostics.map((diagnostic) => (
|
||||||
|
<DismissibleAlert key={`${diagnostic.code}:${diagnostic.message}`} tone="warning" compact>
|
||||||
|
{diagnostic.message}
|
||||||
|
</DismissibleAlert>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Card title={scope === "system" ? "Module policy and tenant selection" : "Tenant module selection"}>
|
||||||
|
<div className="module-management-list">
|
||||||
|
{state.modules.map((module) => {
|
||||||
|
const availability = draftAvailability(draft, module.id);
|
||||||
|
const effectiveSelection = draft.enabled.has(module.id) || availability === "forced" || module.derived_dependency;
|
||||||
|
const selectionLocked = availability !== "available" || module.derived_dependency;
|
||||||
|
return (
|
||||||
|
<div className={`module-management-row${dirtyModule(state, draft, module.id) ? " pending" : ""}`} key={module.id}>
|
||||||
|
<div className="module-management-main">
|
||||||
|
<div className="module-management-title">
|
||||||
|
<strong>{module.name}</strong>
|
||||||
|
<code>{module.id}</code>
|
||||||
|
<StatusBadge
|
||||||
|
status={module.runtime_active ? "success" : "neutral"}
|
||||||
|
label={module.runtime_active ? "Runtime active" : "Runtime inactive"}
|
||||||
|
/>
|
||||||
|
{module.effective && <StatusBadge status="info" label="Effective" />}
|
||||||
|
</div>
|
||||||
|
<div className="module-management-details">
|
||||||
|
<span>{module.dependencies.length ? `Requires ${module.dependencies.join(", ")}` : "No module dependencies"}</span>
|
||||||
|
{module.reason && <span>{module.reason}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="module-management-toggle">
|
||||||
|
{scope === "system" && (
|
||||||
|
<label>
|
||||||
|
<span>System policy</span>
|
||||||
|
<select
|
||||||
|
value={availability}
|
||||||
|
onChange={(event) => setAvailability(module.id, event.target.value as TenantModuleAvailability)}
|
||||||
|
disabled={!canWrite || busy || (module.id === "access" || module.id === "admin")}
|
||||||
|
>
|
||||||
|
<option value="unavailable">Unavailable</option>
|
||||||
|
<option value="available">Available</option>
|
||||||
|
<option value="forced">Forced</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<ToggleSwitch
|
||||||
|
label="Enabled for tenant"
|
||||||
|
checked={effectiveSelection}
|
||||||
|
onChange={(checked) => setEnabled(module.id, checked)}
|
||||||
|
disabled={!canWrite || busy || selectionLocked}
|
||||||
|
help={module.reason || undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !state && (
|
||||||
|
<DismissibleAlert tone="info" dismissible={false}>
|
||||||
|
{scope === "system" ? "No tenant is available for module policy." : "Module policy is unavailable."}
|
||||||
|
</DismissibleAlert>
|
||||||
|
)}
|
||||||
|
</AdminPageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function draftFromState(state: TenantModuleEntitlementResponse): Draft {
|
||||||
|
return {
|
||||||
|
available: new Set(state.available_modules),
|
||||||
|
forced: new Set(state.forced_modules),
|
||||||
|
enabled: new Set(state.selected_modules)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneDraft(draft: Draft): Draft {
|
||||||
|
return {
|
||||||
|
available: new Set(draft.available),
|
||||||
|
forced: new Set(draft.forced),
|
||||||
|
enabled: new Set(draft.enabled)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function draftAvailability(draft: Draft, moduleId: string): TenantModuleAvailability {
|
||||||
|
if (draft.forced.has(moduleId)) return "forced";
|
||||||
|
if (draft.available.has(moduleId)) return "available";
|
||||||
|
return "unavailable";
|
||||||
|
}
|
||||||
|
|
||||||
|
function dirtyModule(state: TenantModuleEntitlementResponse, draft: Draft, moduleId: string): boolean {
|
||||||
|
return state.available_modules.includes(moduleId) !== draft.available.has(moduleId)
|
||||||
|
|| state.forced_modules.includes(moduleId) !== draft.forced.has(moduleId)
|
||||||
|
|| state.selected_modules.includes(moduleId) !== draft.enabled.has(moduleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameSet(left: Set<string>, right: Set<string>): boolean {
|
||||||
|
return left.size === right.size && [...left].every((item) => right.has(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sorted(values: Set<string>): string[] {
|
||||||
|
return [...values].sort();
|
||||||
|
}
|
||||||
@@ -9,5 +9,6 @@ export {
|
|||||||
createTenantReferenceProvider
|
createTenantReferenceProvider
|
||||||
} from "./features/admin/configurationReferenceProviders";
|
} from "./features/admin/configurationReferenceProviders";
|
||||||
export { default as GovernanceTemplatesPanel } from "./features/admin/GovernanceTemplatesPanel";
|
export { default as GovernanceTemplatesPanel } from "./features/admin/GovernanceTemplatesPanel";
|
||||||
|
export { default as TenantModuleManagementPanel } from "./features/admin/TenantModuleManagementPanel";
|
||||||
export { default as SystemSettingsPanel } from "./features/admin/SystemSettingsPanel";
|
export { default as SystemSettingsPanel } from "./features/admin/SystemSettingsPanel";
|
||||||
export type { PlatformWebModule, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext } from "@govoplan/core-webui";
|
export type { PlatformWebModule, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext } from "@govoplan/core-webui";
|
||||||
|
|||||||
+34
-1
@@ -9,6 +9,7 @@ const ConfigurationChangesPanel = lazy(() => import("./features/admin/Configurat
|
|||||||
const ConfigurationPackagesPanel = lazy(() => import("./features/admin/ConfigurationPackagesPanel"));
|
const ConfigurationPackagesPanel = lazy(() => import("./features/admin/ConfigurationPackagesPanel"));
|
||||||
const GovernanceTemplatesPanel = lazy(() => import("./features/admin/GovernanceTemplatesPanel"));
|
const GovernanceTemplatesPanel = lazy(() => import("./features/admin/GovernanceTemplatesPanel"));
|
||||||
const ModuleManagementPanel = lazy(() => import("./features/admin/ModuleManagementPanel"));
|
const ModuleManagementPanel = lazy(() => import("./features/admin/ModuleManagementPanel"));
|
||||||
|
const TenantModuleManagementPanel = lazy(() => import("./features/admin/TenantModuleManagementPanel"));
|
||||||
const SystemSettingsPanel = lazy(() => import("./features/admin/SystemSettingsPanel"));
|
const SystemSettingsPanel = lazy(() => import("./features/admin/SystemSettingsPanel"));
|
||||||
|
|
||||||
const translations = {
|
const translations = {
|
||||||
@@ -108,6 +109,36 @@ const adminSections: AdminSectionsUiCapability = {
|
|||||||
canAccessMaintenance: hasScope(auth, "system:maintenance:access")
|
canAccessMaintenance: hasScope(auth, "system:maintenance:access")
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "system-tenant-modules",
|
||||||
|
moduleId: "admin",
|
||||||
|
kind: "management",
|
||||||
|
surfaceId: "admin.section.system-tenant-modules",
|
||||||
|
label: "Tenant modules",
|
||||||
|
group: "SYSTEM",
|
||||||
|
order: 86,
|
||||||
|
allOf: ["system:settings:read"],
|
||||||
|
render: ({ settings, auth }) => createElement(TenantModuleManagementPanel, {
|
||||||
|
settings,
|
||||||
|
scope: "system",
|
||||||
|
canWrite: hasScope(auth, "system:settings:write")
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tenant-modules",
|
||||||
|
moduleId: "admin",
|
||||||
|
kind: "management",
|
||||||
|
surfaceId: "admin.section.tenant-modules",
|
||||||
|
label: "Modules",
|
||||||
|
group: "TENANT",
|
||||||
|
order: 60,
|
||||||
|
anyOf: ["admin:module:read", "admin:module:write", "system:settings:read"],
|
||||||
|
render: ({ settings, auth }) => createElement(TenantModuleManagementPanel, {
|
||||||
|
settings,
|
||||||
|
scope: "tenant",
|
||||||
|
canWrite: hasScope(auth, "admin:module:write") || hasScope(auth, "system:settings:write")
|
||||||
|
})
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "system-groups",
|
id: "system-groups",
|
||||||
moduleId: "admin",
|
moduleId: "admin",
|
||||||
@@ -140,7 +171,9 @@ export const adminModule: PlatformWebModule = {
|
|||||||
{ id: "admin.section.system-configuration-packages", moduleId: "admin", kind: "section", label: "i18n:govoplan-admin.configuration_packages.eb2f05f1", order: 30 },
|
{ id: "admin.section.system-configuration-packages", moduleId: "admin", kind: "section", label: "i18n:govoplan-admin.configuration_packages.eb2f05f1", order: 30 },
|
||||||
{ id: "admin.section.system-role-templates", moduleId: "admin", kind: "section", label: "i18n:govoplan-admin.tenant_roles.51aca82d", order: 40 },
|
{ id: "admin.section.system-role-templates", moduleId: "admin", kind: "section", label: "i18n:govoplan-admin.tenant_roles.51aca82d", order: 40 },
|
||||||
{ id: "admin.section.system-groups", moduleId: "admin", kind: "section", label: "i18n:govoplan-admin.central_groups.5c9b5b66", order: 50 },
|
{ id: "admin.section.system-groups", moduleId: "admin", kind: "section", label: "i18n:govoplan-admin.central_groups.5c9b5b66", order: 50 },
|
||||||
{ id: "admin.section.system-modules", moduleId: "admin", kind: "section", label: "i18n:govoplan-admin.modules.04e9462c", order: 85 }
|
{ id: "admin.section.system-modules", moduleId: "admin", kind: "section", label: "i18n:govoplan-admin.modules.04e9462c", order: 85 },
|
||||||
|
{ id: "admin.section.system-tenant-modules", moduleId: "admin", kind: "section", label: "Tenant modules", order: 86 },
|
||||||
|
{ id: "admin.section.tenant-modules", moduleId: "admin", kind: "section", label: "Modules", order: 60 }
|
||||||
],
|
],
|
||||||
uiCapabilities: {
|
uiCapabilities: {
|
||||||
"admin.sections": adminSections,
|
"admin.sections": adminSections,
|
||||||
|
|||||||
Reference in New Issue
Block a user