Add tenant module entitlement administration

This commit is contained in:
2026-08-04 08:21:49 +02:00
parent b71523e364
commit 60e2676809
10 changed files with 898 additions and 8 deletions
+19
View File
@@ -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:
+258 -1
View File
@@ -44,6 +44,14 @@ from govoplan_core.core.module_management import (
saved_desired_enabled_modules,
saved_module_install_plan,
)
from govoplan_core.core.module_entitlements import (
ModuleEntitlementConflict,
ModuleEntitlementError,
module_entitlement_payload,
tenant_module_entitlement_state,
update_system_tenant_module_policy,
update_tenant_module_selection,
)
from govoplan_core.core.module_installer import (
ModuleInstallerError,
cancel_module_installer_request,
@@ -110,6 +118,10 @@ from .schemas import (
ModulePackageCatalogResponse,
ModuleLicenseDiagnostics,
ModuleStateUpdateRequest,
SystemTenantModulePolicyUpdateRequest,
TenantModuleEntitlementResponse,
TenantModuleSelectionUpdateRequest,
TenantModuleTargetListResponse,
PrivacyRetentionPolicyItem,
SystemSettingsDeltaResponse,
SystemSettingsItem,
@@ -699,7 +711,7 @@ def _catalog_required_license_features(result: dict[str, object]) -> list[str]:
def admin_overview(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(
"admin:users:read", "admin:groups:read", "admin:roles:read", "admin:settings:read",
"admin:users:read", "admin:groups:read", "admin:roles:read", "admin:settings:read", "admin:module:read", "admin:module:write",
"admin:api_keys:read", "system:tenants:read", "system:accounts:read", "system:roles:read", "system:access:read",
"system:settings:read", "system:governance:read", "system:audit:read",
)),
@@ -736,6 +748,251 @@ def list_system_modules(
return _module_catalog_response(session, request)
def _tenant_or_404(
session: Session,
tenant_id: str,
*,
for_update: bool = False,
) -> Tenant:
query = session.query(Tenant).filter(Tenant.id == tenant_id)
if for_update:
query = query.populate_existing().with_for_update()
tenant = query.one_or_none()
if tenant is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found",
)
return tenant
def _tenant_module_entitlement_response(
request: Request,
tenant: Tenant,
) -> TenantModuleEntitlementResponse:
lifecycle = _request_lifecycle(request)
available = dict(lifecycle.available_modules)
runtime_active = lifecycle.active_module_ids()
state = tenant_module_entitlement_state(
tenant.settings or {},
available,
runtime_active_modules=runtime_active,
)
return TenantModuleEntitlementResponse.model_validate(
module_entitlement_payload(tenant.id, state)
)
@router.get(
"/system/tenant-module-targets",
response_model=TenantModuleTargetListResponse,
)
def list_tenant_module_targets(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
):
del principal
tenants = session.query(Tenant).order_by(Tenant.name.asc(), Tenant.id.asc()).all()
return TenantModuleTargetListResponse(
tenants=[
{
"id": tenant.id,
"slug": tenant.slug,
"name": tenant.name,
"is_active": tenant.is_active,
}
for tenant in tenants
]
)
@router.get(
"/system/tenants/{tenant_id}/modules",
response_model=TenantModuleEntitlementResponse,
)
def read_system_tenant_modules(
tenant_id: str,
request: Request,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
):
del principal
return _tenant_module_entitlement_response(
request,
_tenant_or_404(session, tenant_id),
)
@router.put(
"/system/tenants/{tenant_id}/modules",
response_model=TenantModuleEntitlementResponse,
)
def update_system_tenant_modules(
tenant_id: str,
request: Request,
payload: SystemTenantModulePolicyUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:settings:write")),
):
tenant = _tenant_or_404(session, tenant_id, for_update=True)
lifecycle = _request_lifecycle(request)
available = dict(lifecycle.available_modules)
runtime_active = lifecycle.active_module_ids()
before = tenant_module_entitlement_state(
tenant.settings or {},
available,
runtime_active_modules=runtime_active,
)
policy_value = {
"available_modules": list(payload.available_modules),
"forced_modules": list(payload.forced_modules),
"enabled_modules": list(payload.enabled_modules),
"expected_revision": payload.expected_revision,
}
try:
approval = ensure_configuration_change_allowed(
session,
key="module_entitlements.system_policy",
value=policy_value,
actor_user_id=principal.user.id,
actor_scopes=tuple(principal.scopes),
change_request_id=payload.change_request_id,
target={"scope": "tenant", "tenant_id": tenant.id},
)
updated_settings, state = update_system_tenant_module_policy(
tenant.settings or {},
available,
available_modules=payload.available_modules,
forced_modules=payload.forced_modules,
enabled_modules=payload.enabled_modules,
expected_revision=payload.expected_revision,
runtime_active_modules=runtime_active,
)
except ConfigurationControlError as exc:
session.rollback()
raise _configuration_control_http_error(exc) from exc
except ModuleEntitlementConflict as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
except ModuleEntitlementError as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
tenant.settings = updated_settings
session.add(tenant)
audit_from_principal(
session,
principal,
action="tenant_modules.system_policy_updated",
scope="system",
object_type="tenant_module_entitlement",
object_id=tenant.id,
details=audit_operation_context(
outcome="saved",
tenant_id=tenant.id,
revision=state.revision,
available_modules=list(state.available_modules),
forced_modules=list(state.forced_modules),
selected_modules=list(state.selected_modules),
effective_modules=list(state.effective_modules),
),
)
record_configuration_change_applied(
session,
key="module_entitlements.system_policy",
before_value=module_entitlement_payload(tenant.id, before),
after_value=module_entitlement_payload(tenant.id, state),
actor_user_id=principal.user.id,
approval=approval,
target={"scope": "tenant", "tenant_id": tenant.id},
audit_event="tenant_modules.system_policy_updated",
)
session.commit()
return TenantModuleEntitlementResponse.model_validate(
module_entitlement_payload(tenant.id, state)
)
@router.get(
"/tenant/modules",
response_model=TenantModuleEntitlementResponse,
)
def read_tenant_modules(
request: Request,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_any_scope(
"admin:module:read",
"admin:module:write",
"system:settings:read",
)
),
):
return _tenant_module_entitlement_response(
request,
_tenant_or_404(session, principal.tenant_id),
)
@router.put(
"/tenant/modules",
response_model=TenantModuleEntitlementResponse,
)
def update_tenant_modules(
request: Request,
payload: TenantModuleSelectionUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_any_scope("admin:module:write", "system:settings:write")
),
):
tenant = _tenant_or_404(session, principal.tenant_id, for_update=True)
lifecycle = _request_lifecycle(request)
available = dict(lifecycle.available_modules)
runtime_active = lifecycle.active_module_ids()
try:
updated_settings, state = update_tenant_module_selection(
tenant.settings or {},
available,
enabled_modules=payload.enabled_modules,
expected_revision=payload.expected_revision,
runtime_active_modules=runtime_active,
)
except ModuleEntitlementConflict as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
except ModuleEntitlementError as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
tenant.settings = updated_settings
session.add(tenant)
audit_from_principal(
session,
principal,
action="tenant_modules.selection_updated",
scope="tenant",
object_type="tenant_module_entitlement",
object_id=tenant.id,
details=audit_operation_context(
outcome="saved",
tenant_id=tenant.id,
revision=state.revision,
selected_modules=list(state.selected_modules),
effective_modules=list(state.effective_modules),
),
)
session.commit()
return TenantModuleEntitlementResponse.model_validate(
module_entitlement_payload(tenant.id, state)
)
@router.get("/system/modules/install-plan", response_model=ModuleInstallPlanResponse)
def read_module_install_plan(
request: Request,
@@ -120,6 +120,61 @@ class ModuleStateUpdateRequest(BaseModel):
change_request_id: str | None = None
class TenantModuleEntitlementItem(BaseModel):
id: str
name: str
dependencies: list[str] = Field(default_factory=list)
runtime_active: bool
availability: Literal["unavailable", "available", "forced"]
selected: bool
effective: bool
forced: bool
derived_dependency: bool
tenant_can_toggle: bool
reason: str | None = None
class TenantModuleEntitlementResponse(BaseModel):
tenant_id: str
revision: int
configured: bool
available_modules: list[str] = Field(default_factory=list)
forced_modules: list[str] = Field(default_factory=list)
selected_modules: list[str] = Field(default_factory=list)
effective_modules: list[str] = Field(default_factory=list)
derived_dependencies: list[str] = Field(default_factory=list)
modules: list[TenantModuleEntitlementItem] = Field(default_factory=list)
diagnostics: list[dict[str, str]] = Field(default_factory=list)
class TenantModuleTargetItem(BaseModel):
id: str
slug: str
name: str
is_active: bool
class TenantModuleTargetListResponse(BaseModel):
tenants: list[TenantModuleTargetItem] = Field(default_factory=list)
class SystemTenantModulePolicyUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
available_modules: list[str] = Field(default_factory=list, max_length=1000)
forced_modules: list[str] = Field(default_factory=list, max_length=1000)
enabled_modules: list[str] = Field(default_factory=list, max_length=1000)
expected_revision: int | None = Field(default=None, ge=0)
change_request_id: str | None = None
class TenantModuleSelectionUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
enabled_modules: list[str] = Field(default_factory=list, max_length=1000)
expected_revision: int | None = Field(default=None, ge=0)
class ModuleInstallPlanItem(BaseModel):
model_config = ConfigDict(extra="forbid")
+69 -1
View File
@@ -3,7 +3,15 @@ from __future__ import annotations
from govoplan_admin.backend.db import models as admin_models # noqa: F401 - populate Admin ORM metadata
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.module_guards import persistent_table_uninstall_guard
from govoplan_core.core.modules import DocumentationTopic, FrontendModule, MigrationSpec, ModuleContext, ModuleManifest
from govoplan_core.core.modules import (
DocumentationTopic,
FrontendModule,
MigrationSpec,
ModuleContext,
ModuleManifest,
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
@@ -16,10 +24,49 @@ def _route_factory(context: ModuleContext):
return router
ADMIN_PERMISSIONS = (
PermissionDefinition(
scope="admin:module:read",
module_id="admin",
resource="module",
action="read",
label="View tenant modules",
description="Inspect module availability, requirements, and effective state for the active tenant.",
category="Administration",
level="tenant",
),
PermissionDefinition(
scope="admin:module:write",
module_id="admin",
resource="module",
action="write",
label="Manage tenant modules",
description="Enable or disable modules for the active tenant within system policy.",
category="Administration",
level="tenant",
),
)
ADMIN_ROLE_TEMPLATES = (
RoleTemplate(
slug="module_admin",
name="Module administrator",
description="Manage the active tenant's module selection within system policy.",
permissions=("admin:module:read", "admin:module:write"),
level="tenant",
managed=True,
protected=False,
),
)
manifest = ModuleManifest(
id="admin",
name="Admin",
version="0.1.8",
permissions=ADMIN_PERMISSIONS,
role_templates=ADMIN_ROLE_TEMPLATES,
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
route_factory=_route_factory,
documentation=(
@@ -39,6 +86,25 @@ manifest = ModuleManifest(
],
},
),
DocumentationTopic(
id="admin.tenant-module-entitlements",
title="Govern modules per tenant",
summary="System administrators set each tenant's module ceiling and forced modules; tenant module administrators choose within that ceiling.",
body=(
"Deployment activation installs and loads module code for the whole instance. Tenant module governance is a separate entitlement layer: system administrators mark modules unavailable, available, or forced for a tenant and may also change that tenant's selection. A tenant module administrator can only enable or disable available modules; forced modules and required dependencies remain effective. Module entitlement never grants permissions, and malformed policy fails closed to protected administration modules. Enabling a capability module such as Encryption only makes its services available; data encryption remains an explicit owning-module policy or migration decision."
),
layer="configured",
documentation_types=("admin",),
audience=("system_admin", "module_admin", "tenant_admin"),
related_modules=("access", "policy", "views", "encryption"),
metadata={
"kind": "workflow",
"help_contexts": [
"admin.system-tenant-modules",
"admin.tenant-modules",
],
},
),
DocumentationTopic(
id="admin.governance-and-module-lifecycle",
title="Govern configuration and module lifecycle",
@@ -96,6 +162,8 @@ manifest = ModuleManifest(
ViewSurface(id="admin.section.system-role-templates", module_id="admin", kind="section", label="Role templates", order=40),
ViewSurface(id="admin.section.system-groups", module_id="admin", kind="section", label="Group templates", order=50),
ViewSurface(id="admin.section.system-modules", module_id="admin", kind="section", label="Modules", order=85),
ViewSurface(id="admin.section.system-tenant-modules", module_id="admin", kind="section", label="Tenant modules", order=86),
ViewSurface(id="admin.section.tenant-modules", module_id="admin", kind="section", label="Modules", order=60),
),
),
migration_spec=MigrationSpec(module_id="admin", metadata=Base.metadata),
@@ -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()
+76
View File
@@ -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<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> {
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}>
<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-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-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")} />}
@@ -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-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-view-policy") && <AreaLink title="View policy" text="Limit View actions, definitions, and surfaces across the instance." onClick={() => onSelect("system-view-policy")} />}
</div>
</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-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-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-audit") && <AreaLink title="i18n:govoplan-admin.audit.fa1703dd" text="i18n:govoplan-admin.tenant_level_administrative_history_only.55495c3c" onClick={() => onSelect("tenant-audit")} />}
</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-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("group-view-policy") && <AreaLink title="View policy" text="Set View limits for a selected group." onClick={() => onSelect("group-view-policy")} />}
</div>
</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-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("user-view-policy") && <AreaLink title="View policy" text="Set View limits for a selected user." onClick={() => onSelect("user-view-policy")} />}
</div>
</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 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<string>, candidates: readonly string[]): boolean {
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();
}
+1
View File
@@ -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";
+34 -1
View File
@@ -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,