From d6e7c8b0b1c4ce9a3e1f709992cc8e9cd46d8e47 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 4 Aug 2026 05:20:47 +0200 Subject: [PATCH] Add governed module and interface controls --- docs/MODULE_ARCHITECTURE.md | 52 ++ docs/UI_UX_DECISION_LEDGER.md | 5 + src/govoplan_core/auth/__init__.py | 4 + src/govoplan_core/core/lifecycle.py | 63 ++- src/govoplan_core/core/module_entitlements.py | 483 ++++++++++++++++++ src/govoplan_core/core/platform_interfaces.py | 303 +++++++++++ src/govoplan_core/core/registry.py | 7 + src/govoplan_core/security/permissions.py | 2 + src/govoplan_core/server/platform.py | 90 +++- tests/test_module_entitlements.py | 293 +++++++++++ tests/test_platform_interface_catalog.py | 131 +++++ webui/scripts/audit-i18n-structural.mjs | 12 +- .../scripts/test-core-interface-patterns.mjs | 18 + webui/src/api/platform.ts | 6 +- webui/src/components/Button.tsx | 7 +- webui/src/components/DateTimeField.tsx | 15 +- webui/src/components/FormField.tsx | 12 +- webui/src/components/ModuleLoadBoundary.tsx | 2 +- webui/src/components/SearchableSelect.tsx | 9 +- webui/src/components/ToggleSwitch.tsx | 7 +- .../components/email/EmailAddressInput.tsx | 9 +- webui/src/layout/IconRail.tsx | 43 +- webui/src/layout/Titlebar.tsx | 57 ++- webui/src/styles/components.css | 2 + webui/src/styles/layout.css | 104 +++- webui/src/types.ts | 34 ++ webui/src/utils/permissions.ts | 4 + 27 files changed, 1700 insertions(+), 74 deletions(-) create mode 100644 src/govoplan_core/core/module_entitlements.py create mode 100644 src/govoplan_core/core/platform_interfaces.py create mode 100644 tests/test_module_entitlements.py create mode 100644 tests/test_platform_interface_catalog.py diff --git a/docs/MODULE_ARCHITECTURE.md b/docs/MODULE_ARCHITECTURE.md index bb8a640..294aebe 100644 --- a/docs/MODULE_ARCHITECTURE.md +++ b/docs/MODULE_ARCHITECTURE.md @@ -834,6 +834,24 @@ the shared loading and retryable error state around route rendering. The initial static import closure and largest asynchronous chunk are enforced by the budgets documented in [WEBUI_BUNDLE_BUDGETS.md](WEBUI_BUNDLE_BUDGETS.md). +Every public platform interface has a stable declaration identity. Backend +routes, capabilities, interfaces, search providers/sources, permissions, +frontend routes/navigation, and View surfaces derive that identity from typed +`ModuleManifest` values. Typed WebUI capabilities declare IDs for settings, +admin sections, widgets, search contexts, and extension actions. Shared form +and action controls accept `interfaceId` and `helpTopicId`; use module-namespaced +values when another contract, documentation topic, or automated check must +refer to the control across source changes. The static inventory assigns a +line-independent source anchor when an explicit ID is absent and reports that +fact for later review. + +Core exposes the sanitized runtime declaration set at +`GET /api/v1/platform/interface-catalog`. The endpoint is read-only, requires +`admin:module:read` or `system:settings:read`, and includes only modules +effective in the caller's active tenant context. It never serializes factories, +credentials, executable callbacks, or mutable module state. Registry validation +rejects conflicting declaration IDs before startup. + WebUI modules receive only the core route context: - `settings` @@ -1239,6 +1257,40 @@ devserver, development bootstrap, background worker registry, and migration metadata plan all read the saved desired state from `system_settings` before building their module registry. +### Tenant entitlement and personal visibility + +Deployment activation remains process-wide: one installed and active registry +is shared by every tenant served by that process. Tenant module selection is a +separate entitlement document in `core_scopes.settings.module_entitlements`: + +- a system policy marks each installed module `unavailable`, `available`, or + `forced` for one tenant; +- the tenant selection may enable or disable only available modules; +- protected platform modules, forced modules, and transitive dependencies stay + effective; +- malformed explicit entitlement fails closed to protected modules, while an + absent document preserves the pre-entitlement behavior for upgraded tenants; +- an optimistic revision prevents concurrent system and tenant administrators + from silently replacing each other's changes. + +The authenticated platform metadata and module route guard intersect global +runtime activation with the active tenant's effective entitlement. Entitlement +does not grant a permission. Access authorization must still allow every API +operation and resource. + +Users and groups do not own another module-runtime state. Every WebUI module +already contributes a root `.module` View surface, so personal and +group module visibility is expressed through Views. View policy controls who +may select, assign, edit, derive, or workflow-activate those projections; +required View assignments can retain required UI. Thus tenant entitlement owns +operational availability, Views own presentation, and Access owns authority. + +Capability-style modules such as Encryption must keep activation separate from +domain data state. Making Encryption effective only exposes its capability and +administration surfaces. Encrypting, rekeying, decrypting, or migrating data is +an explicit versioned protection-policy operation owned by Encryption and the +module that owns the data. + Hot enable/disable is a core design principle for every module: - Core keeps one mutable active `PlatformRegistry` object and swaps its manifest diff --git a/docs/UI_UX_DECISION_LEDGER.md b/docs/UI_UX_DECISION_LEDGER.md index 5ca5dfd..0b9ea47 100644 --- a/docs/UI_UX_DECISION_LEDGER.md +++ b/docs/UI_UX_DECISION_LEDGER.md @@ -50,6 +50,11 @@ contestability, responsibility, and traceability at the point of action. | UX-024 | Explicit `Discard` actions and dirty in-application navigation use the shared `UnsavedChangesProvider` dialog. A page registers save/discard behavior with `useUnsavedDraftGuard`; its Discard button calls `requestDiscard`, and route changes use `useGuardedNavigate` or `requestNavigation`. | Accepted | All create/edit surfaces | | UX-025 | `window.alert` and the global `alert` function are prohibited. A narrowly necessary exception requires product-owner authorization and an entry in the alert exception register before implementation. | Accepted | All WebUI code | | UX-026 | A table defines one stable ordered action set. A row-level unavailable action remains in its normal position and is disabled, preferably with `disabledReason`; structurally irrelevant actions are omitted for the entire table. Empty rows reserve the same slots so their Add action stays in the normal left-most action position. | Accepted | All structured tables | +| UX-027 | The platform icon rail keeps its brand header and utility footer visible. Only the module-navigation region scrolls when installed and permitted modules exceed the available viewport height. | Accepted | Core WebUI shell | +| UX-028 | Maintenance and offline state change the titlebar surface and repeat a quiet status label behind its controls. They must not replace, cover, or intercept the centered global-search surface; an accessible status control remains in the leading titlebar area. | Accepted | Core WebUI shell | +| UX-029 | Recoverable page and module errors use the central compact `DismissibleAlert` presentation with an explicit recovery action where one exists. Full-height workspaces must overlay page feedback instead of allowing an alert to become a stretched workspace row. | Accepted | Core and module WebUIs | +| UX-030 | At narrow widths, the titlebar uses separate context and command rows. Context selectors remain horizontally reachable, search retains its compact trigger, and language/help/notification/account commands remain fixed icon controls without overlap. Shared content padding contracts so domain workspaces retain usable width. | Accepted | Core WebUI shell and all module workspaces | +| UX-031 | Public controls and extension contributions use stable, module-namespaced interface identities. Shared controls expose `interfaceId` and `helpTopicId`; generated source anchors are inventory evidence, not a substitute for an explicit ID when documentation, policy, or automation refers to the control. | Accepted | Core and module WebUIs | ## Confirmed Implementation Decisions diff --git a/src/govoplan_core/auth/__init__.py b/src/govoplan_core/auth/__init__.py index eedb6c5..a3fe689 100644 --- a/src/govoplan_core/auth/__init__.py +++ b/src/govoplan_core/auth/__init__.py @@ -135,6 +135,9 @@ def get_api_principal( authorization: str | None = Header(default=None), x_api_key: str | None = Header(default=None, alias="X-API-Key"), ) -> ApiPrincipal: + cached = getattr(request.state, "govoplan_api_principal", None) + if isinstance(cached, ApiPrincipal): + return cached principal = _api_principal_provider_from_request(request).resolve_api_principal( request, session, @@ -143,6 +146,7 @@ def get_api_principal( ) if not isinstance(principal, ApiPrincipal): raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Invalid API principal") + request.state.govoplan_api_principal = principal return principal diff --git a/src/govoplan_core/core/lifecycle.py b/src/govoplan_core/core/lifecycle.py index a846195..6506561 100644 --- a/src/govoplan_core/core/lifecycle.py +++ b/src/govoplan_core/core/lifecycle.py @@ -4,9 +4,13 @@ from collections.abc import Mapping, Sequence from dataclasses import dataclass from threading import RLock -from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, status +from fastapi import APIRouter, Depends, FastAPI, Header, HTTPException, Request, status +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session +from govoplan_core.auth import ApiPrincipal, get_api_principal from govoplan_core.core.module_management import ModuleManagementError, REQUIRED_PLATFORM_MODULES, plan_desired_enabled_modules +from govoplan_core.core.module_entitlements import tenant_module_entitlement_state from govoplan_core.core.module_lifecycle_recovery import ( ModuleLifecycleRecovery, begin_runtime_graph_recovery, @@ -18,7 +22,9 @@ from govoplan_core.core.runtime import configure_runtime from govoplan_core.core.workflows import ( workflow_definition_contribution_provider, ) +from govoplan_core.db.session import get_session from govoplan_core.server.route_validation import validate_router_can_mount +from govoplan_core.tenancy.scope import Tenant @dataclass(frozen=True, slots=True) @@ -31,11 +37,60 @@ class ModuleLifecycleResult: def require_module_active(module_id: str): - def dependency(request: Request) -> None: + def dependency( + request: Request, + session: Session = Depends(get_session), + authorization: str | None = Header(default=None), + x_api_key: str | None = Header(default=None, alias="X-API-Key"), + ) -> None: registry = getattr(request.app.state, "govoplan_registry", None) - if isinstance(registry, PlatformRegistry) and registry.has_module(module_id): + if not isinstance(registry, PlatformRegistry) or not registry.has_module(module_id): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Module is disabled: {module_id}") + + # Public module routes must remain reachable without Access. When an + # authenticated request is present, cache its principal and apply the + # active tenant's module entitlement before the owning route executes. + if not authorization and not x_api_key and not request.cookies: return - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Module is disabled: {module_id}") + try: + principal = get_api_principal( + request, + session, + authorization=authorization, + x_api_key=x_api_key, + ) + except HTTPException as exc: + if exc.status_code in { + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN, + }: + return + raise + if not isinstance(principal, ApiPrincipal) or principal.principal.tenant_id is None: + return + try: + tenant = session.get(Tenant, principal.principal.tenant_id) + except (RuntimeError, SQLAlchemyError) as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Tenant module entitlement could not be resolved.", + ) from exc + if tenant is None: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="The active tenant is unavailable.", + ) + manifests = {manifest.id: manifest for manifest in registry.manifests()} + entitlement = tenant_module_entitlement_state( + tenant.settings or {}, + manifests, + runtime_active_modules=manifests, + ) + if module_id not in entitlement.effective_modules: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Module is unavailable in the active tenant: {module_id}", + ) return dependency diff --git a/src/govoplan_core/core/module_entitlements.py b/src/govoplan_core/core/module_entitlements.py new file mode 100644 index 0000000..e8cf06f --- /dev/null +++ b/src/govoplan_core/core/module_entitlements.py @@ -0,0 +1,483 @@ +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from dataclasses import dataclass +from typing import Any + +from govoplan_core.core.modules import ModuleManifest + + +MODULE_ENTITLEMENTS_KEY = "module_entitlements" +MODULE_ENTITLEMENT_SCHEMA_VERSION = 1 +TENANT_PROTECTED_MODULES = ("access", "admin") + + +class ModuleEntitlementError(ValueError): + pass + + +class ModuleEntitlementConflict(ModuleEntitlementError): + pass + + +@dataclass(frozen=True, slots=True) +class TenantModuleItem: + id: str + name: str + dependencies: tuple[str, ...] + runtime_active: bool + availability: str + selected: bool + effective: bool + forced: bool + derived_dependency: bool + tenant_can_toggle: bool + reason: str | None = None + + +@dataclass(frozen=True, slots=True) +class TenantModuleEntitlementState: + revision: int + configured: bool + available_modules: tuple[str, ...] + forced_modules: tuple[str, ...] + selected_modules: tuple[str, ...] + effective_modules: tuple[str, ...] + derived_dependencies: tuple[str, ...] + modules: tuple[TenantModuleItem, ...] + diagnostics: tuple[dict[str, str], ...] = () + + +def tenant_module_entitlement_state( + settings: Mapping[str, object] | None, + manifests: Mapping[str, ModuleManifest], + *, + runtime_active_modules: Iterable[str] | None = None, + protected_modules: Iterable[str] = TENANT_PROTECTED_MODULES, +) -> TenantModuleEntitlementState: + module_ids = tuple(sorted(manifests)) + known = set(module_ids) + runtime_active = ( + known + if runtime_active_modules is None + else known.intersection(_normalized_ids(runtime_active_modules)) + ) + protected = known.intersection(_normalized_ids(protected_modules)) + raw_document = (settings or {}).get(MODULE_ENTITLEMENTS_KEY) + configured = isinstance(raw_document, Mapping) + diagnostics: list[dict[str, str]] = [] + + if not configured: + revision = 0 + requested_available = set(known) + requested_forced = set(protected) + requested_selected = set(known) + else: + document = raw_document + revision = _revision(document.get("revision"), diagnostics) + system_policy = document.get("system_policy") + tenant_selection = document.get("tenant_selection") + if not isinstance(system_policy, Mapping) or not isinstance( + tenant_selection, Mapping + ): + diagnostics.append( + _diagnostic( + "module_entitlements.invalid_document", + "The tenant module entitlement document is malformed and was restricted to protected modules.", + ) + ) + requested_available = set(protected) + requested_forced = set(protected) + requested_selected = set() + else: + requested_available = _configured_ids( + system_policy.get("available_modules"), + field="system_policy.available_modules", + known=known, + fallback=protected, + diagnostics=diagnostics, + ) + requested_forced = _configured_ids( + system_policy.get("forced_modules"), + field="system_policy.forced_modules", + known=known, + fallback=protected, + diagnostics=diagnostics, + ) + requested_selected = _configured_ids( + tenant_selection.get("enabled_modules"), + field="tenant_selection.enabled_modules", + known=known, + fallback=(), + diagnostics=diagnostics, + ) + + available, missing_available = _dependency_closure( + requested_available | requested_forced | protected, + manifests, + ) + forced, missing_forced = _dependency_closure( + requested_forced | protected, + manifests, + ) + selected = requested_selected.intersection(available) + effective_candidates, missing_selected = _dependency_closure( + selected | forced, + manifests, + ) + effective_candidates.intersection_update(available) + effective = effective_candidates.intersection(runtime_active) + derived = effective_candidates - selected - forced + + for module_id in sorted( + missing_available | missing_forced | missing_selected + ): + diagnostics.append( + _diagnostic( + "module_entitlements.missing_dependency", + f"A selected module requires unavailable dependency {module_id}.", + ) + ) + + items: list[TenantModuleItem] = [] + for module_id in module_ids: + manifest = manifests[module_id] + is_available = module_id in available + is_forced = module_id in forced + is_selected = module_id in selected + is_derived = module_id in derived + is_runtime_active = module_id in runtime_active + is_effective = module_id in effective + reason: str | None = None + if not is_available: + reason = "Unavailable by system policy." + elif is_forced: + reason = "Required by system policy or a protected platform dependency." + elif is_derived: + reason = "Required by another selected module." + elif not is_runtime_active and (is_selected or is_forced): + reason = "Selected for this tenant, but the module is not active in the deployment." + items.append( + TenantModuleItem( + id=module_id, + name=manifest.name, + dependencies=tuple(manifest.dependencies), + runtime_active=is_runtime_active, + availability=( + "forced" if is_forced else "available" if is_available else "unavailable" + ), + selected=is_selected, + effective=is_effective, + forced=is_forced, + derived_dependency=is_derived, + tenant_can_toggle=is_available and not is_forced and not is_derived, + reason=reason, + ) + ) + + return TenantModuleEntitlementState( + revision=revision, + configured=configured, + available_modules=tuple(sorted(available)), + forced_modules=tuple(sorted(forced)), + selected_modules=tuple(sorted(selected)), + effective_modules=tuple(sorted(effective)), + derived_dependencies=tuple(sorted(derived)), + modules=tuple(items), + diagnostics=tuple(diagnostics), + ) + + +def update_system_tenant_module_policy( + settings: Mapping[str, object] | None, + manifests: Mapping[str, ModuleManifest], + *, + available_modules: Iterable[str], + forced_modules: Iterable[str], + enabled_modules: Iterable[str], + expected_revision: int | None, + runtime_active_modules: Iterable[str] | None = None, + protected_modules: Iterable[str] = TENANT_PROTECTED_MODULES, +) -> tuple[dict[str, object], TenantModuleEntitlementState]: + current = tenant_module_entitlement_state( + settings, + manifests, + runtime_active_modules=runtime_active_modules, + protected_modules=protected_modules, + ) + _check_revision(current.revision, expected_revision) + known = set(manifests) + available_requested = _validated_requested_ids( + available_modules, known=known, field="available_modules" + ) + forced_requested = _validated_requested_ids( + forced_modules, known=known, field="forced_modules" + ) + enabled_requested = _validated_requested_ids( + enabled_modules, known=known, field="enabled_modules" + ) + protected = known.intersection(_normalized_ids(protected_modules)) + available, missing = _dependency_closure( + available_requested | forced_requested | protected, + manifests, + ) + forced, forced_missing = _dependency_closure( + forced_requested | protected, + manifests, + ) + if missing or forced_missing: + missing_text = ", ".join(sorted(missing | forced_missing)) + raise ModuleEntitlementError( + f"Module policy references dependencies that are not installed: {missing_text}" + ) + unavailable_enabled = enabled_requested - available + if unavailable_enabled: + raise ModuleEntitlementError( + "Tenant selection contains modules unavailable by system policy: " + + ", ".join(sorted(unavailable_enabled)) + ) + _validate_enabled_dependencies(enabled_requested | forced, available, manifests) + updated = _write_document( + settings, + revision=current.revision + 1, + available_modules=available, + forced_modules=forced, + enabled_modules=enabled_requested, + ) + return updated, tenant_module_entitlement_state( + updated, + manifests, + runtime_active_modules=runtime_active_modules, + protected_modules=protected_modules, + ) + + +def update_tenant_module_selection( + settings: Mapping[str, object] | None, + manifests: Mapping[str, ModuleManifest], + *, + enabled_modules: Iterable[str], + expected_revision: int | None, + runtime_active_modules: Iterable[str] | None = None, + protected_modules: Iterable[str] = TENANT_PROTECTED_MODULES, +) -> tuple[dict[str, object], TenantModuleEntitlementState]: + current = tenant_module_entitlement_state( + settings, + manifests, + runtime_active_modules=runtime_active_modules, + protected_modules=protected_modules, + ) + _check_revision(current.revision, expected_revision) + enabled = _validated_requested_ids( + enabled_modules, + known=set(manifests), + field="enabled_modules", + ) + unavailable = enabled - set(current.available_modules) + if unavailable: + raise ModuleEntitlementError( + "Tenant selection contains modules unavailable by system policy: " + + ", ".join(sorted(unavailable)) + ) + _validate_enabled_dependencies( + enabled | set(current.forced_modules), + set(current.available_modules), + manifests, + ) + updated = _write_document( + settings, + revision=current.revision + 1, + available_modules=current.available_modules, + forced_modules=current.forced_modules, + enabled_modules=enabled, + ) + return updated, tenant_module_entitlement_state( + updated, + manifests, + runtime_active_modules=runtime_active_modules, + protected_modules=protected_modules, + ) + + +def module_entitlement_payload( + tenant_id: str, + state: TenantModuleEntitlementState, +) -> dict[str, Any]: + return { + "tenant_id": tenant_id, + "revision": state.revision, + "configured": state.configured, + "available_modules": list(state.available_modules), + "forced_modules": list(state.forced_modules), + "selected_modules": list(state.selected_modules), + "effective_modules": list(state.effective_modules), + "derived_dependencies": list(state.derived_dependencies), + "modules": [ + { + "id": item.id, + "name": item.name, + "dependencies": list(item.dependencies), + "runtime_active": item.runtime_active, + "availability": item.availability, + "selected": item.selected, + "effective": item.effective, + "forced": item.forced, + "derived_dependency": item.derived_dependency, + "tenant_can_toggle": item.tenant_can_toggle, + "reason": item.reason, + } + for item in state.modules + ], + "diagnostics": [dict(item) for item in state.diagnostics], + } + + +def _write_document( + settings: Mapping[str, object] | None, + *, + revision: int, + available_modules: Iterable[str], + forced_modules: Iterable[str], + enabled_modules: Iterable[str], +) -> dict[str, object]: + updated = dict(settings or {}) + updated[MODULE_ENTITLEMENTS_KEY] = { + "schema_version": MODULE_ENTITLEMENT_SCHEMA_VERSION, + "revision": revision, + "system_policy": { + "available_modules": sorted(set(available_modules)), + "forced_modules": sorted(set(forced_modules)), + }, + "tenant_selection": { + "enabled_modules": sorted(set(enabled_modules)), + }, + } + return updated + + +def _validate_enabled_dependencies( + enabled: set[str], + available: set[str], + manifests: Mapping[str, ModuleManifest], +) -> None: + closure, missing = _dependency_closure(enabled, manifests) + if missing: + raise ModuleEntitlementError( + "Selected modules require dependencies that are not installed: " + + ", ".join(sorted(missing)) + ) + unavailable = closure - available + if unavailable: + raise ModuleEntitlementError( + "Selected modules require dependencies unavailable by system policy: " + + ", ".join(sorted(unavailable)) + ) + + +def _dependency_closure( + requested: Iterable[str], + manifests: Mapping[str, ModuleManifest], +) -> tuple[set[str], set[str]]: + closure: set[str] = set() + missing: set[str] = set() + pending = list(_normalized_ids(requested)) + while pending: + module_id = pending.pop() + if module_id in closure: + continue + manifest = manifests.get(module_id) + if manifest is None: + missing.add(module_id) + continue + closure.add(module_id) + pending.extend(manifest.dependencies) + return closure, missing + + +def _configured_ids( + value: object, + *, + field: str, + known: set[str], + fallback: Iterable[str], + diagnostics: list[dict[str, str]], +) -> set[str]: + if not isinstance(value, list | tuple): + diagnostics.append( + _diagnostic( + "module_entitlements.invalid_field", + f"{field} is malformed and was evaluated with a restrictive fallback.", + ) + ) + return set(fallback) + values = _normalized_ids(value) + unknown = values - known + if unknown: + diagnostics.append( + _diagnostic( + "module_entitlements.unknown_module", + f"{field} references unknown modules: {', '.join(sorted(unknown))}.", + ) + ) + return values.intersection(known) + + +def _validated_requested_ids( + values: Iterable[str], + *, + known: set[str], + field: str, +) -> set[str]: + normalized = _normalized_ids(values) + unknown = normalized - known + if unknown: + raise ModuleEntitlementError( + f"{field} contains unknown modules: {', '.join(sorted(unknown))}" + ) + return normalized + + +def _normalized_ids(values: Iterable[object]) -> set[str]: + return { + clean + for value in values + if (clean := str(value).strip()) + } + + +def _revision(value: object, diagnostics: list[dict[str, str]]) -> int: + if isinstance(value, int) and value >= 0: + return value + diagnostics.append( + _diagnostic( + "module_entitlements.invalid_revision", + "The module entitlement revision is invalid; concurrent updates will require a reload.", + ) + ) + return 0 + + +def _check_revision(current: int, expected: int | None) -> None: + if expected is not None and expected != current: + raise ModuleEntitlementConflict( + f"Module entitlement revision changed from {expected} to {current}; reload before saving." + ) + + +def _diagnostic(code: str, message: str) -> dict[str, str]: + return {"code": code, "message": message, "severity": "warning"} + + +__all__ = [ + "MODULE_ENTITLEMENTS_KEY", + "MODULE_ENTITLEMENT_SCHEMA_VERSION", + "TENANT_PROTECTED_MODULES", + "ModuleEntitlementConflict", + "ModuleEntitlementError", + "TenantModuleEntitlementState", + "TenantModuleItem", + "module_entitlement_payload", + "tenant_module_entitlement_state", + "update_system_tenant_module_policy", + "update_tenant_module_selection", +] diff --git a/src/govoplan_core/core/platform_interfaces.py b/src/govoplan_core/core/platform_interfaces.py new file mode 100644 index 0000000..22d94ef --- /dev/null +++ b/src/govoplan_core/core/platform_interfaces.py @@ -0,0 +1,303 @@ +from __future__ import annotations + +from collections import Counter +from dataclasses import dataclass, field +import hashlib +import json +import re +from typing import Any, Literal, Mapping, Sequence + +from govoplan_core.core.modules import ModuleManifest +from govoplan_core.core.views import ( + navigation_view_surface_id, + route_view_surface_id, +) + + +PLATFORM_INTERFACE_CONTRACT_VERSION = "1" + +PlatformInterfaceKind = Literal[ + "backend_capability", + "frontend_route", + "navigation", + "permission", + "provided_interface", + "public_route", + "search_provider", + "search_source", + "settings_route", + "view_surface", +] + + +@dataclass(frozen=True, slots=True) +class PlatformInterfaceDeclaration: + """A sanitized, stable declaration from a module manifest. + + The declaration contains identifiers and authorization metadata only. It + deliberately excludes factories, executable callbacks, credentials, and + mutable module state. + """ + + id: str + module_id: str + kind: PlatformInterfaceKind + label: str | None = None + path: str | None = None + required_all: tuple[str, ...] = () + required_any: tuple[str, ...] = () + metadata: Mapping[str, Any] = field(default_factory=dict) + + @property + def key(self) -> str: + return f"{self.kind}:{self.id}" + + def to_dict(self) -> dict[str, Any]: + return { + "key": self.key, + "id": self.id, + "module_id": self.module_id, + "kind": self.kind, + "label": self.label, + "path": self.path, + "required_all": list(self.required_all), + "required_any": list(self.required_any), + "metadata": dict(self.metadata), + } + + +def manifest_interface_declarations( + manifest: ModuleManifest, +) -> tuple[PlatformInterfaceDeclaration, ...]: + """Normalize the typed public declarations owned by one module manifest.""" + + declarations: list[PlatformInterfaceDeclaration] = [] + + for capability_name in sorted(manifest.capability_factories): + documentation = manifest.capability_documentation.get(capability_name) + declarations.append( + PlatformInterfaceDeclaration( + id=capability_name, + module_id=manifest.id, + kind="backend_capability", + label=documentation.label if documentation is not None else None, + metadata={ + "contract_version": ( + documentation.contract_version + if documentation is not None + else None + ), + }, + ) + ) + + for interface in manifest.provides_interfaces: + declarations.append( + PlatformInterfaceDeclaration( + id=interface.name, + module_id=manifest.id, + kind="provided_interface", + metadata={"version": interface.version}, + ) + ) + + for permission in manifest.permissions: + declarations.append( + PlatformInterfaceDeclaration( + id=permission.scope, + module_id=manifest.id, + kind="permission", + label=permission.label, + metadata={ + "category": permission.category, + "level": permission.level, + "deprecated": permission.deprecated, + }, + ) + ) + + for registration in manifest.search_providers: + declarations.append( + PlatformInterfaceDeclaration( + id=registration.id, + module_id=manifest.id, + kind="search_provider", + metadata={ + "role": "provider", + "resource_types": list(registration.resource_types), + }, + ) + ) + for registration in manifest.search_sources: + declarations.append( + PlatformInterfaceDeclaration( + id=registration.id, + module_id=manifest.id, + kind="search_source", + metadata={"role": "source"}, + ) + ) + + frontend = manifest.frontend + if frontend is not None: + for route in frontend.routes: + declarations.append( + PlatformInterfaceDeclaration( + id=route_view_surface_id(manifest.id, route.path), + module_id=manifest.id, + kind="frontend_route", + path=route.path, + required_all=route.required_all, + required_any=route.required_any, + metadata={ + "component": route.component, + "order": route.order, + "surface_id": route.surface_id, + }, + ) + ) + for route in frontend.public_routes: + declarations.append( + PlatformInterfaceDeclaration( + id=f"{manifest.id}.public.{_path_slug(route.path)}", + module_id=manifest.id, + kind="public_route", + path=route.path, + metadata={"component": route.component, "order": route.order}, + ) + ) + for route in frontend.settings_routes: + declarations.append( + PlatformInterfaceDeclaration( + id=route_view_surface_id(manifest.id, route.path), + module_id=manifest.id, + kind="settings_route", + path=route.path, + required_all=route.required_all, + required_any=route.required_any, + metadata={ + "component": route.component, + "order": route.order, + "surface_id": route.surface_id, + }, + ) + ) + for item in frontend.nav_items: + declarations.append( + PlatformInterfaceDeclaration( + id=navigation_view_surface_id(manifest.id, item.path), + module_id=manifest.id, + kind="navigation", + label=item.label, + path=item.path, + required_all=item.required_all, + required_any=item.required_any, + metadata={ + "icon": item.icon, + "section": item.section, + "order": item.order, + "surface_id": item.surface_id, + }, + ) + ) + for surface in frontend.view_surfaces: + declarations.append( + PlatformInterfaceDeclaration( + id=surface.id, + module_id=manifest.id, + kind="view_surface", + label=surface.label, + metadata={ + "surface_kind": surface.kind, + "parent_id": surface.parent_id, + "description": surface.description, + "order": surface.order, + "default_visible": surface.default_visible, + "required": surface.required, + }, + ) + ) + + frontend_navigation = { + declaration.id: declaration + for declaration in declarations + if declaration.kind == "navigation" + } + for item in manifest.nav_items: + declaration = PlatformInterfaceDeclaration( + id=navigation_view_surface_id(manifest.id, item.path), + module_id=manifest.id, + kind="navigation", + label=item.label, + path=item.path, + required_all=item.required_all, + required_any=item.required_any, + metadata={ + "icon": item.icon, + "section": item.section, + "order": item.order, + "surface_id": item.surface_id, + }, + ) + frontend_declaration = frontend_navigation.get(declaration.id) + if frontend_declaration is not None and frontend_declaration == declaration: + continue + declarations.append(declaration) + + return tuple(sorted(declarations, key=lambda item: (item.kind, item.id))) + + +def validate_manifest_interface_declarations(manifest: ModuleManifest) -> None: + seen: set[str] = set() + for declaration in manifest_interface_declarations(manifest): + if declaration.key in seen: + raise ValueError( + f"Module {manifest.id!r} declares duplicate platform interface " + f"{declaration.key!r}" + ) + seen.add(declaration.key) + + +def manifest_interface_catalog(manifest: ModuleManifest) -> dict[str, Any]: + declarations = manifest_interface_declarations(manifest) + serialized = [item.to_dict() for item in declarations] + canonical = json.dumps( + serialized, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + return { + "contract_version": PLATFORM_INTERFACE_CONTRACT_VERSION, + "module_id": manifest.id, + "module_version": manifest.version, + "digest": f"sha256:{hashlib.sha256(canonical).hexdigest()}", + "counts": dict(sorted(Counter(item.kind for item in declarations).items())), + "declarations": serialized, + } + + +def platform_interface_catalog( + manifests: Sequence[ModuleManifest], +) -> dict[str, Any]: + modules = [manifest_interface_catalog(manifest) for manifest in manifests] + return { + "contract_version": PLATFORM_INTERFACE_CONTRACT_VERSION, + "modules": modules, + } + + +def _path_slug(path: str) -> str: + slug = re.sub(r"[^a-z0-9]+", ".", path.lower()).strip(".") + return slug or "root" + + +__all__ = [ + "PLATFORM_INTERFACE_CONTRACT_VERSION", + "PlatformInterfaceDeclaration", + "PlatformInterfaceKind", + "manifest_interface_catalog", + "manifest_interface_declarations", + "platform_interface_catalog", + "validate_manifest_interface_declarations", +] diff --git a/src/govoplan_core/core/registry.py b/src/govoplan_core/core/registry.py index 5c14cf8..bf85af0 100644 --- a/src/govoplan_core/core/registry.py +++ b/src/govoplan_core/core/registry.py @@ -29,6 +29,9 @@ from govoplan_core.core.ownership import ( OwnershipProviderRegistration, ResourceOwnershipProvider, ) +from govoplan_core.core.platform_interfaces import ( + validate_manifest_interface_declarations, +) from govoplan_core.core.provider_governance import ( ExternalProviderDeclaration, ExternalProviderStateProviderRegistration, @@ -646,6 +649,10 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None: _validate_manifest_overlaps(manifest) _validate_manifest_migration_spec(manifest) _validate_manifest_frontend(manifest) + try: + validate_manifest_interface_declarations(manifest) + except ValueError as exc: + raise RegistryError(str(exc)) from exc for item in manifest.nav_items: _validate_nav_item(manifest.id, item) for topic in manifest.documentation: diff --git a/src/govoplan_core/security/permissions.py b/src/govoplan_core/security/permissions.py index 3dfb9bc..af92e0c 100644 --- a/src/govoplan_core/security/permissions.py +++ b/src/govoplan_core/security/permissions.py @@ -69,6 +69,8 @@ TENANT_PERMISSIONS: tuple[PermissionDefinition, ...] = ( PermissionDefinition("admin:settings:write", "Manage tenant settings", "Change tenant defaults and non-policy settings.", "Tenant administration"), PermissionDefinition("admin:policies:read", "View tenant policies", "Read tenant policy and governance settings.", "Tenant administration"), PermissionDefinition("admin:policies:write", "Manage tenant policies", "Change tenant policy and governance settings where system policy permits it.", "Tenant administration"), + PermissionDefinition("admin:module:read", "View tenant modules", "Inspect module availability, requirements, and effective state for the active tenant.", "Tenant administration"), + PermissionDefinition("admin:module:write", "Manage tenant modules", "Enable or disable modules for the active tenant within system policy.", "Tenant administration"), ) SYSTEM_PERMISSIONS: tuple[PermissionDefinition, ...] = ( diff --git a/src/govoplan_core/server/platform.py b/src/govoplan_core/server/platform.py index 01a7b6f..3f3a163 100644 --- a/src/govoplan_core/server/platform.py +++ b/src/govoplan_core/server/platform.py @@ -5,9 +5,17 @@ from sqlalchemy.exc import SQLAlchemyError from govoplan_core.admin.models import SystemSettings from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID -from govoplan_core.auth import ApiPrincipal, get_api_principal +from govoplan_core.auth import ApiPrincipal, get_api_principal, require_any_scope from govoplan_core.core.maintenance import saved_maintenance_mode +from govoplan_core.core.module_entitlements import ( + module_entitlement_payload, + tenant_module_entitlement_state, +) from govoplan_core.core.modules import FrontendModule, FrontendRoute, ModuleManifest, NavItem, PublicFrontendRoute +from govoplan_core.core.platform_interfaces import ( + manifest_interface_catalog, + platform_interface_catalog, +) from govoplan_core.core.registry import PlatformRegistry, manifest_view_surfaces from govoplan_core.core.views import ( VIEW_SURFACE_CONTRACT_VERSION, @@ -17,6 +25,7 @@ from govoplan_core.core.views import ( ) from govoplan_core.db.session import get_database from govoplan_core.i18n import system_i18n_payload +from govoplan_core.tenancy.scope import Tenant def _registry(request: Request) -> PlatformRegistry: @@ -26,6 +35,49 @@ def _registry(request: Request) -> PlatformRegistry: return registry +def _effective_manifest_state( + request: Request, + principal: ApiPrincipal, +) -> tuple[PlatformRegistry, tuple[ModuleManifest, ...], object | None]: + """Resolve only manifests available in the principal's active context.""" + + registry = _registry(request) + manifests = tuple(registry.manifests()) + entitlement = None + principal_ref = getattr(principal, "principal", None) + tenant_id = getattr(principal_ref, "tenant_id", None) + if tenant_id is not None: + try: + with get_database().session() as session: + tenant = session.get(Tenant, tenant_id) + if tenant is None: + raise HTTPException( + status_code=403, + detail="The active tenant is unavailable.", + ) + manifest_map = {manifest.id: manifest for manifest in manifests} + entitlement = tenant_module_entitlement_state( + tenant.settings or {}, + manifest_map, + runtime_active_modules=manifest_map, + ) + except (RuntimeError, SQLAlchemyError) as exc: + raise HTTPException( + status_code=503, + detail="Tenant module entitlement could not be resolved.", + ) from exc + effective_ids = ( + set(entitlement.effective_modules) + if entitlement is not None + else {manifest.id for manifest in manifests} + ) + return ( + registry, + tuple(manifest for manifest in manifests if manifest.id in effective_ids), + entitlement, + ) + + def _nav_item_payload(item: NavItem, module_id: str | None = None) -> dict[str, object]: return { "path": item.path, @@ -156,9 +208,14 @@ def create_platform_router(settings: object | None = None) -> APIRouter: @router.get("/modules") def modules( request: Request, - _principal: ApiPrincipal = Depends(get_api_principal), + principal: ApiPrincipal = Depends(get_api_principal), ): - registry = _registry(request) + registry, manifests, entitlement = _effective_manifest_state( + request, + principal, + ) + principal_ref = getattr(principal, "principal", None) + tenant_id = getattr(principal_ref, "tenant_id", None) return { "modules": [ { @@ -178,13 +235,36 @@ def create_platform_router(settings: object | None = None) -> APIRouter: for declaration in manifest.external_providers ], "runtime_ui_capabilities": _runtime_ui_capabilities(manifest.id, settings, registry), + "interface_catalog": { + key: value + for key, value in manifest_interface_catalog(manifest).items() + if key != "declarations" + }, "nav": [_nav_item_payload(item, manifest.id) for item in manifest.nav_items], "frontend": _frontend_payload(manifest), } - for manifest in registry.manifests() - ] + for manifest in manifests + ], + "module_entitlement": ( + module_entitlement_payload(tenant_id, entitlement) + if tenant_id is not None and entitlement is not None + else None + ), } + @router.get("/interface-catalog") + def interface_catalog( + request: Request, + principal: ApiPrincipal = Depends( + require_any_scope("admin:module:read", "system:settings:read") + ), + ): + _registry_item, manifests, _entitlement = _effective_manifest_state( + request, + principal, + ) + return platform_interface_catalog(manifests) + @router.get("/public-modules") def public_modules(request: Request): registry = _registry(request) diff --git a/tests/test_module_entitlements.py b/tests/test_module_entitlements.py new file mode 100644 index 0000000..61e2bf2 --- /dev/null +++ b/tests/test_module_entitlements.py @@ -0,0 +1,293 @@ +from __future__ import annotations + +import unittest +from pathlib import Path +import tempfile +from unittest.mock import patch + +from fastapi import APIRouter, Depends, FastAPI +from fastapi.testclient import TestClient + +from govoplan_core.auth import ApiPrincipal, get_api_principal +from govoplan_core.core.access import PrincipalRef +from govoplan_core.core.lifecycle import require_module_active +from govoplan_core.core.module_entitlements import ( + ModuleEntitlementConflict, + ModuleEntitlementError, + tenant_module_entitlement_state, + update_system_tenant_module_policy, + update_tenant_module_selection, +) +from govoplan_core.core.modules import ModuleManifest +from govoplan_core.core.registry import PlatformRegistry +from govoplan_core.db.session import configure_database, get_database +from govoplan_core.server.platform import create_platform_router +from govoplan_core.tenancy.scope import Tenant, create_scope_tables + + +class TenantModuleEntitlementTests(unittest.TestCase): + def setUp(self) -> None: + self.manifests = { + "access": ModuleManifest(id="access", name="Access", version="test"), + "admin": ModuleManifest( + id="admin", + name="Admin", + version="test", + dependencies=("access",), + ), + "files": ModuleManifest( + id="files", + name="Files", + version="test", + dependencies=("access",), + ), + "campaigns": ModuleManifest( + id="campaigns", + name="Campaigns", + version="test", + dependencies=("access", "files"), + ), + "encryption": ModuleManifest( + id="encryption", + name="Encryption", + version="test", + ), + } + + def test_unconfigured_tenant_preserves_current_module_visibility(self) -> None: + state = tenant_module_entitlement_state({}, self.manifests) + + self.assertFalse(state.configured) + self.assertEqual(set(self.manifests), set(state.effective_modules)) + self.assertEqual({"access", "admin"}, set(state.forced_modules)) + + def test_system_policy_closes_dependencies_and_tenant_selection(self) -> None: + settings, state = update_system_tenant_module_policy( + {}, + self.manifests, + available_modules=("campaigns", "encryption"), + forced_modules=("campaigns",), + enabled_modules=("encryption",), + expected_revision=0, + ) + + self.assertEqual(1, state.revision) + self.assertEqual( + {"access", "admin", "files", "campaigns", "encryption"}, + set(state.available_modules), + ) + self.assertEqual( + {"access", "admin", "files", "campaigns"}, + set(state.forced_modules), + ) + self.assertEqual({"encryption"}, set(state.selected_modules)) + self.assertEqual(set(self.manifests), set(state.effective_modules)) + self.assertIn("module_entitlements", settings) + + def test_tenant_cannot_enable_system_unavailable_module(self) -> None: + settings, _state = update_system_tenant_module_policy( + {}, + self.manifests, + available_modules=("files",), + forced_modules=(), + enabled_modules=(), + expected_revision=0, + ) + + with self.assertRaisesRegex( + ModuleEntitlementError, + "unavailable by system policy: encryption", + ): + update_tenant_module_selection( + settings, + self.manifests, + enabled_modules=("encryption",), + expected_revision=1, + ) + + def test_forced_modules_remain_effective_when_tenant_selection_is_empty(self) -> None: + settings, _state = update_system_tenant_module_policy( + {}, + self.manifests, + available_modules=("campaigns",), + forced_modules=("campaigns",), + enabled_modules=(), + expected_revision=0, + ) + _settings, state = update_tenant_module_selection( + settings, + self.manifests, + enabled_modules=(), + expected_revision=1, + ) + + self.assertEqual( + {"access", "admin", "files", "campaigns"}, + set(state.effective_modules), + ) + self.assertTrue( + all( + not item.tenant_can_toggle + for item in state.modules + if item.id in state.forced_modules + ) + ) + + def test_inactive_runtime_module_is_selected_but_not_effective(self) -> None: + settings, state = update_system_tenant_module_policy( + {}, + self.manifests, + available_modules=("files", "encryption"), + forced_modules=(), + enabled_modules=("encryption",), + expected_revision=0, + runtime_active_modules=("access", "admin", "files"), + ) + + self.assertIn("encryption", state.selected_modules) + self.assertNotIn("encryption", state.effective_modules) + encryption = next(item for item in state.modules if item.id == "encryption") + self.assertIn("not active in the deployment", encryption.reason or "") + self.assertIn("module_entitlements", settings) + + def test_stale_revision_is_rejected(self) -> None: + settings, _state = update_system_tenant_module_policy( + {}, + self.manifests, + available_modules=("files",), + forced_modules=(), + enabled_modules=("files",), + expected_revision=0, + ) + + with self.assertRaises(ModuleEntitlementConflict): + update_tenant_module_selection( + settings, + self.manifests, + enabled_modules=(), + expected_revision=0, + ) + + def test_malformed_document_fails_closed_to_protected_modules(self) -> None: + state = tenant_module_entitlement_state( + {"module_entitlements": {"revision": "invalid"}}, + self.manifests, + ) + + self.assertEqual({"access", "admin"}, set(state.effective_modules)) + self.assertTrue(state.diagnostics) + + +class TenantModuleEntitlementRouteTests(unittest.TestCase): + def setUp(self) -> None: + root = Path(tempfile.mkdtemp(prefix="govoplan-entitlement-test-")) + configure_database(f"sqlite:///{root / 'test.db'}") + create_scope_tables(get_database().engine) + self.manifests = ( + ModuleManifest(id="access", name="Access", version="test"), + ModuleManifest( + id="admin", + name="Admin", + version="test", + dependencies=("access",), + ), + ModuleManifest( + id="files", + name="Files", + version="test", + dependencies=("access",), + ), + ) + self.registry = PlatformRegistry() + for manifest in self.manifests: + self.registry.register(manifest) + settings, _state = update_system_tenant_module_policy( + {}, + {manifest.id: manifest for manifest in self.manifests}, + available_modules=(), + forced_modules=(), + enabled_modules=(), + expected_revision=0, + ) + with get_database().session() as session: + session.add( + Tenant( + id="tenant-1", + slug="tenant-1", + name="Tenant 1", + settings=settings, + ) + ) + session.commit() + self.principal = ApiPrincipal( + principal=PrincipalRef( + account_id="account-1", + membership_id="membership-1", + tenant_id="tenant-1", + ), + account=object(), + user=object(), + ) + + def test_platform_metadata_excludes_tenant_unavailable_module(self) -> None: + app = FastAPI() + app.state.govoplan_registry = self.registry + app.include_router(create_platform_router(), prefix="/api/v1") + app.dependency_overrides[get_api_principal] = lambda: self.principal + + with TestClient(app) as client: + response = client.get("/api/v1/platform/modules") + + self.assertEqual(200, response.status_code, response.text) + self.assertEqual( + {"access", "admin"}, + {item["id"] for item in response.json()["modules"]}, + ) + self.assertNotIn( + "files", + response.json()["module_entitlement"]["effective_modules"], + ) + + def test_authenticated_module_route_is_hidden_when_tenant_unavailable(self) -> None: + app = FastAPI() + app.state.govoplan_registry = self.registry + guarded = APIRouter(dependencies=[Depends(require_module_active("files"))]) + + @guarded.get("/files") + def files_route(): + return {"ok": True} + + app.include_router(guarded) + with patch( + "govoplan_core.core.lifecycle.get_api_principal", + return_value=self.principal, + ), TestClient(app) as client: + response = client.get( + "/files", + headers={"Authorization": "Bearer test"}, + ) + + self.assertEqual(404, response.status_code, response.text) + self.assertEqual( + "Module is unavailable in the active tenant: files", + response.json()["detail"], + ) + + def test_unauthenticated_public_route_is_not_turned_into_login(self) -> None: + app = FastAPI() + app.state.govoplan_registry = self.registry + guarded = APIRouter(dependencies=[Depends(require_module_active("files"))]) + + @guarded.get("/public-files") + def public_files_route(): + return {"ok": True} + + app.include_router(guarded) + with TestClient(app) as client: + response = client.get("/public-files") + + self.assertEqual(200, response.status_code, response.text) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_platform_interface_catalog.py b/tests/test_platform_interface_catalog.py new file mode 100644 index 0000000..29ff3c9 --- /dev/null +++ b/tests/test_platform_interface_catalog.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import unittest + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from govoplan_core.auth import ApiPrincipal, get_api_principal +from govoplan_core.core.access import PrincipalRef +from govoplan_core.core.modules import ( + FrontendModule, + FrontendRoute, + ModuleInterfaceProvider, + ModuleManifest, + NavItem, +) +from govoplan_core.core.platform_interfaces import ( + manifest_interface_catalog, + manifest_interface_declarations, +) +from govoplan_core.core.registry import PlatformRegistry, RegistryError +from govoplan_core.server.platform import create_platform_router + + +def _principal(*scopes: str) -> ApiPrincipal: + return ApiPrincipal( + principal=PrincipalRef( + account_id="account-1", + membership_id=None, + tenant_id=None, + scopes=frozenset(scopes), + ), + account=object(), + user=object(), + ) + + +def _manifest() -> ModuleManifest: + navigation = NavItem(path="/example", label="Example", icon="box") + return ModuleManifest( + id="example", + name="Example", + version="1.2.3", + provides_interfaces=( + ModuleInterfaceProvider(name="example.reader", version="1.0.0"), + ), + capability_factories={"example.reader": lambda _context: object()}, + nav_items=(navigation,), + frontend=FrontendModule( + module_id="example", + routes=( + FrontendRoute(path="/example", component="ExamplePage"), + ), + nav_items=(navigation,), + ), + ) + + +class PlatformInterfaceCatalogTests(unittest.TestCase): + def test_manifest_declarations_have_stable_typed_keys(self) -> None: + declarations = manifest_interface_declarations(_manifest()) + keys = {item.key for item in declarations} + + self.assertIn("backend_capability:example.reader", keys) + self.assertIn("provided_interface:example.reader", keys) + self.assertIn("frontend_route:example.route.example", keys) + self.assertIn("navigation:example.nav.example", keys) + self.assertEqual( + 1, + sum(item.key == "navigation:example.nav.example" for item in declarations), + ) + + def test_catalog_digest_is_deterministic(self) -> None: + first = manifest_interface_catalog(_manifest()) + second = manifest_interface_catalog(_manifest()) + + self.assertEqual(first["digest"], second["digest"]) + self.assertEqual("1", first["contract_version"]) + + def test_registry_rejects_conflicting_duplicate_navigation(self) -> None: + manifest = _manifest() + manifest = ModuleManifest( + id=manifest.id, + name=manifest.name, + version=manifest.version, + nav_items=manifest.nav_items, + frontend=FrontendModule( + module_id="example", + nav_items=(NavItem(path="/example", label="Other"),), + ), + ) + registry = PlatformRegistry() + registry.register(manifest) + + with self.assertRaisesRegex(RegistryError, "duplicate platform interface"): + registry.validate() + + def test_read_only_endpoint_requires_administrator_scope(self) -> None: + registry = PlatformRegistry() + registry.register(_manifest()) + registry.validate() + app = FastAPI() + app.state.govoplan_registry = registry + app.include_router(create_platform_router(), prefix="/api/v1") + + app.dependency_overrides[get_api_principal] = lambda: _principal() + with TestClient(app) as client: + denied = client.get("/api/v1/platform/interface-catalog") + self.assertEqual(403, denied.status_code) + + app.dependency_overrides[get_api_principal] = lambda: _principal( + "admin:module:read" + ) + with TestClient(app) as client: + response = client.get("/api/v1/platform/interface-catalog") + + self.assertEqual(200, response.status_code) + payload = response.json() + self.assertEqual("1", payload["contract_version"]) + self.assertEqual(["example"], [item["module_id"] for item in payload["modules"]]) + self.assertIn( + "frontend_route:example.route.example", + { + item["key"] + for item in payload["modules"][0]["declarations"] + }, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/scripts/audit-i18n-structural.mjs b/webui/scripts/audit-i18n-structural.mjs index c0e7cd7..b0fb2fa 100644 --- a/webui/scripts/audit-i18n-structural.mjs +++ b/webui/scripts/audit-i18n-structural.mjs @@ -40,7 +40,7 @@ function scanStructuralSourcePositions(roots) { } if (ts.isPropertyAssignment(node)) { const name = nodeName(node.name); - if ((isStructuralName(name) || isAlgorithmNameProperty(node) || isListOptionValueProperty(node)) && hasI18nLiteral(node.initializer)) report(node, `object ${name}`); + if ((isStructuralName(name) || isAlgorithmNameProperty(node) || isListOptionValueProperty(node)) && hasI18nLiteral(node.initializer) && !isPresentationalBlockerProperty(node)) report(node, `object ${name}`); if (hasI18nLiteral(node.name) && !isAllowedStructuralStringPosition(node.name, file)) report(node, "object key"); } if (ts.isShorthandPropertyAssignment(node) && node.name.text.includes("i18n:")) report(node, "object key"); @@ -221,6 +221,16 @@ function isStructuralJsxValue(node) { return parent.tagName.getText() === "option"; } +function isPresentationalBlockerProperty(node) { + if (nodeName(node.name) !== "target" || !ts.isObjectLiteralExpression(node.parent)) return false; + const propertyNames = new Set( + node.parent.properties + .filter((property) => ts.isPropertyAssignment(property)) + .map((property) => nodeName(property.name)) + ); + return propertyNames.has("actor") && propertyNames.has("requiredAction"); +} + function hasAncestorVariable(node, variableName) { let current = node.parent; while (current) { diff --git a/webui/scripts/test-core-interface-patterns.mjs b/webui/scripts/test-core-interface-patterns.mjs index 50ffab0..d28175c 100644 --- a/webui/scripts/test-core-interface-patterns.mjs +++ b/webui/scripts/test-core-interface-patterns.mjs @@ -9,6 +9,10 @@ const read = (path) => readFileSync(resolve(webuiRoot, path), "utf8"); const settings = read("src/features/settings/SettingsPage.tsx"); const retention = read("src/features/privacy/RetentionPolicyManagement.tsx"); const credentials = read("src/components/CredentialEnvelopeManager.tsx"); +const iconRail = read("src/layout/IconRail.tsx"); +const moduleLoadBoundary = read("src/components/ModuleLoadBoundary.tsx"); +const titlebar = read("src/layout/Titlebar.tsx"); +const layoutStyles = read("src/styles/layout.css"); assert.match(settings, /contextId: "core\.settings"/, "settings expose stable contextual documentation"); assert.match(settings, /There are no unsaved profile changes\./, "profile save explains its clean state"); @@ -24,4 +28,18 @@ assert.match(credentials, /contextId: "access\.credentials"/, "credentials expos assert.match(credentials, /disabledReason: writeDisabledReason/, "credential row actions retain actionable disabled reasons"); assert.doesNotMatch(credentials, /