From 60e26768090e0bc0c1ff0d583a52a02cf03d8434 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 4 Aug 2026 08:21:49 +0200 Subject: [PATCH] Add tenant module entitlement administration --- README.md | 19 + src/govoplan_admin/backend/api/v1/routes.py | 259 ++++++++++++- src/govoplan_admin/backend/api/v1/schemas.py | 55 +++ src/govoplan_admin/backend/manifest.py | 70 +++- .../test_interface_documentation_contract.py | 13 + webui/src/api/admin.ts | 76 ++++ .../src/features/admin/AdminOverviewPanel.tsx | 16 +- .../admin/TenantModuleManagementPanel.tsx | 362 ++++++++++++++++++ webui/src/index.ts | 1 + webui/src/module.ts | 35 +- 10 files changed, 898 insertions(+), 8 deletions(-) create mode 100644 webui/src/features/admin/TenantModuleManagementPanel.tsx diff --git a/README.md b/README.md index 2eb0e4e..7b1cc5f 100644 --- a/README.md +++ b/README.md @@ -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 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 The admin UI intentionally exposes two different package concepts: diff --git a/src/govoplan_admin/backend/api/v1/routes.py b/src/govoplan_admin/backend/api/v1/routes.py index bb1b842..19a7847 100644 --- a/src/govoplan_admin/backend/api/v1/routes.py +++ b/src/govoplan_admin/backend/api/v1/routes.py @@ -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, diff --git a/src/govoplan_admin/backend/api/v1/schemas.py b/src/govoplan_admin/backend/api/v1/schemas.py index be50a5b..7beb53b 100644 --- a/src/govoplan_admin/backend/api/v1/schemas.py +++ b/src/govoplan_admin/backend/api/v1/schemas.py @@ -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") diff --git a/src/govoplan_admin/backend/manifest.py b/src/govoplan_admin/backend/manifest.py index 8beef04..0f76c88 100644 --- a/src/govoplan_admin/backend/manifest.py +++ b/src/govoplan_admin/backend/manifest.py @@ -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), diff --git a/tests/test_interface_documentation_contract.py b/tests/test_interface_documentation_contract.py index 7e9d20d..0d8a424 100644 --- a/tests/test_interface_documentation_contract.py +++ b/tests/test_interface_documentation_contract.py @@ -22,6 +22,10 @@ class InterfaceDocumentationContractTests(unittest.TestCase): "admin.module-lifecycle.plan", "admin.module-lifecycle.evidence", }, + "admin.tenant-module-entitlements": { + "admin.system-tenant-modules", + "admin.tenant-modules", + }, } for topic_id, contexts in expected.items(): self.assertIn(topic_id, topics) @@ -45,9 +49,18 @@ class InterfaceDocumentationContractTests(unittest.TestCase): "admin.section.system-role-templates", "admin.section.system-groups", "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__": unittest.main() diff --git a/webui/src/api/admin.ts b/webui/src/api/admin.ts index ab0c8c6..3574a3e 100644 --- a/webui/src/api/admin.ts +++ b/webui/src/api/admin.ts @@ -186,6 +186,42 @@ export type ModuleCatalogResponse = { 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 = { module_id: string; action: "install" | "update" | "uninstall"; @@ -504,6 +540,46 @@ export function updateModuleState(settings: ApiSettings, enabledModules: string[ }); } +export function fetchSystemTenantModules(settings: ApiSettings, tenantId: string): Promise { + return apiFetch(settings, `/api/v1/admin/system/tenants/${encodeURIComponent(tenantId)}/modules`); +} + +export async function fetchTenantModuleTargets(settings: ApiSettings): Promise { + 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 { + return apiFetch(settings, `/api/v1/admin/system/tenants/${encodeURIComponent(tenantId)}/modules`, { + method: "PUT", + body: JSON.stringify(payload) + }); +} + +export function fetchTenantModules(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/admin/tenant/modules"); +} + +export function updateTenantModules( + settings: ApiSettings, + payload: { enabled_modules: string[]; expected_revision: number } +): Promise { + return apiFetch(settings, "/api/v1/admin/tenant/modules", { + method: "PUT", + body: JSON.stringify(payload) + }); +} + export function fetchModuleInstallPlan(settings: ApiSettings): Promise { return apiFetch(settings, "/api/v1/admin/system/modules/install-plan"); } diff --git a/webui/src/features/admin/AdminOverviewPanel.tsx b/webui/src/features/admin/AdminOverviewPanel.tsx index 876829a..d8005ad 100644 --- a/webui/src/features/admin/AdminOverviewPanel.tsx +++ b/webui/src/features/admin/AdminOverviewPanel.tsx @@ -44,6 +44,7 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio {hasAnySection(availableSections, platformSectionIds) &&
{availableSections.has("system-modules") && onSelect("system-modules")} />} + {availableSections.has("system-tenant-modules") && onSelect("system-tenant-modules")} />} {availableSections.has("system-configuration-packages") && onSelect("system-configuration-packages")} />} {availableSections.has("system-settings") && onSelect("system-settings")} />} {availableSections.has("system-configuration-changes") && onSelect("system-configuration-changes")} />} @@ -61,6 +62,7 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio {availableSections.has("system-file-connectors") && onSelect("system-file-connectors")} />} {availableSections.has("system-mail-servers") && onSelect("system-mail-servers")} />} {availableSections.has("system-retention") && onSelect("system-retention")} />} + {availableSections.has("system-view-policy") && onSelect("system-view-policy")} />}
} @@ -81,6 +83,8 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio {availableSections.has("tenant-mail-servers") && onSelect("tenant-mail-servers")} />} {availableSections.has("tenant-api-keys") && onSelect("tenant-api-keys")} />} {availableSections.has("tenant-retention") && onSelect("tenant-retention")} />} + {availableSections.has("tenant-modules") && onSelect("tenant-modules")} />} + {availableSections.has("tenant-view-policy") && onSelect("tenant-view-policy")} />} {availableSections.has("tenant-settings") && onSelect("tenant-settings")} />} {availableSections.has("tenant-audit") && onSelect("tenant-audit")} />} @@ -91,6 +95,7 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio {availableSections.has("tenant-group-file-connectors") && onSelect("tenant-group-file-connectors")} />} {availableSections.has("tenant-group-mail-servers") && onSelect("tenant-group-mail-servers")} />} {availableSections.has("tenant-group-retention") && onSelect("tenant-group-retention")} />} + {availableSections.has("group-view-policy") && onSelect("group-view-policy")} />} } @@ -99,6 +104,7 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio {availableSections.has("tenant-user-file-connectors") && onSelect("tenant-user-file-connectors")} />} {availableSections.has("tenant-user-mail-servers") && onSelect("tenant-user-mail-servers")} />} {availableSections.has("tenant-user-retention") && onSelect("tenant-user-retention")} />} + {availableSections.has("user-view-policy") && onSelect("user-view-policy")} />} } } @@ -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 globalSectionIds = ["system-tenants", "system-roles", "system-role-templates", "system-groups", "system-users", "system-file-connectors", "system-mail-servers", "system-retention"]; -const tenantSectionIds = ["tenant-roles", "tenant-groups", "tenant-users", "tenant-file-connectors", "tenant-mail-servers", "tenant-api-keys", "tenant-retention", "tenant-settings", "tenant-audit"]; -const groupSectionIds = ["tenant-group-file-connectors", "tenant-group-mail-servers", "tenant-group-retention"]; -const userSectionIds = ["tenant-user-file-connectors", "tenant-user-mail-servers", "tenant-user-retention"]; +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", "system-view-policy"]; +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", "group-view-policy"]; +const userSectionIds = ["tenant-user-file-connectors", "tenant-user-mail-servers", "tenant-user-retention", "user-view-policy"]; function hasAnySection(sections: ReadonlySet, candidates: readonly string[]): boolean { return candidates.some((section) => sections.has(section)); diff --git a/webui/src/features/admin/TenantModuleManagementPanel.tsx b/webui/src/features/admin/TenantModuleManagementPanel.tsx new file mode 100644 index 0000000..977b4cf --- /dev/null +++ b/webui/src/features/admin/TenantModuleManagementPanel.tsx @@ -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; + forced: Set; + enabled: Set; +}; + +const DOCUMENTATION = { + contextId: "admin.tenant-modules", + documentationType: "admin" as const +}; + +export default function TenantModuleManagementPanel({ settings, scope, canWrite }: Props) { + const [targets, setTargets] = useState([]); + const [targetId, setTargetId] = useState(""); + const [state, setState] = useState(null); + const [draft, setDraft] = useState(null); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + + const targetOptions = useMemo(() => 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 { + 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 ( + + + + + + + } + > + {scope === "system" && ( + + void selectTarget(value)} + placeholder="Select tenant" + searchPlaceholder="Search tenants..." + disabled={loading || busy || targetOptions.length === 0} + /> + + )} + + {state && draft && ( + <> +
+ + + + +
+ + {state.diagnostics.map((diagnostic) => ( + + {diagnostic.message} + + ))} + + +
+ {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 ( +
+
+
+ {module.name} + {module.id} + + {module.effective && } +
+
+ {module.dependencies.length ? `Requires ${module.dependencies.join(", ")}` : "No module dependencies"} + {module.reason && {module.reason}} +
+
+
+ {scope === "system" && ( + + )} + setEnabled(module.id, checked)} + disabled={!canWrite || busy || selectionLocked} + help={module.reason || undefined} + /> +
+
+ ); + })} +
+
+ + )} + + {!loading && !state && ( + + {scope === "system" ? "No tenant is available for module policy." : "Module policy is unavailable."} + + )} +
+ ); +} + +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, right: Set): boolean { + return left.size === right.size && [...left].every((item) => right.has(item)); +} + +function sorted(values: Set): string[] { + return [...values].sort(); +} diff --git a/webui/src/index.ts b/webui/src/index.ts index 63f7585..b860782 100644 --- a/webui/src/index.ts +++ b/webui/src/index.ts @@ -9,5 +9,6 @@ export { createTenantReferenceProvider } from "./features/admin/configurationReferenceProviders"; 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 type { PlatformWebModule, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext } from "@govoplan/core-webui"; diff --git a/webui/src/module.ts b/webui/src/module.ts index 248eaaf..e602f9d 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -9,6 +9,7 @@ const ConfigurationChangesPanel = lazy(() => import("./features/admin/Configurat const ConfigurationPackagesPanel = lazy(() => import("./features/admin/ConfigurationPackagesPanel")); const GovernanceTemplatesPanel = lazy(() => import("./features/admin/GovernanceTemplatesPanel")); const ModuleManagementPanel = lazy(() => import("./features/admin/ModuleManagementPanel")); +const TenantModuleManagementPanel = lazy(() => import("./features/admin/TenantModuleManagementPanel")); const SystemSettingsPanel = lazy(() => import("./features/admin/SystemSettingsPanel")); const translations = { @@ -108,6 +109,36 @@ const adminSections: AdminSectionsUiCapability = { 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", 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-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-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: { "admin.sections": adminSections,