diff --git a/README.md b/README.md index 79a50bf..d7c2ce5 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,13 @@ use their normal permission checks. Required Views retain the View selector and administration surfaces needed to inspect and change the assignment, preventing administrators from locking an installation out of its own configuration. +Views are also the canonical user/group module-visibility mechanism. Core +creates a root `.module` surface for every WebUI module, so a View may +hide or reveal a tenant-effective module without creating a second personal +module-state store. A View cannot make a module operationally available when +system tenant policy excludes it, and it cannot grant the Access permissions +needed to use the module. + ## Administration The module contributes **Views** sections to system and tenant administration. diff --git a/src/govoplan_views/backend/manifest.py b/src/govoplan_views/backend/manifest.py index a7deaf4..69cd766 100644 --- a/src/govoplan_views/backend/manifest.py +++ b/src/govoplan_views/backend/manifest.py @@ -349,8 +349,11 @@ manifest = ModuleManifest( "and system assignments. Pinning preserves one published revision; an " "unpinned assignment follows later publications. Required Views must keep " "the selector and administration escape surfaces. Hiding a surface never " - "grants or revokes authorization, and inherited definitions or assignments " - "must be changed in their owning scope." + "grants or revokes authorization. The surface catalogue is constrained by " + "the active tenant's module entitlement, so a View cannot expose a module " + "that system policy made unavailable or the tenant disabled. Saved references " + "to such surfaces remain in immutable revisions and are reported as stale. " + "Inherited definitions or assignments must be changed in their owning scope." ), documentation_types=("admin",), audience=("administrator", "power_user", "workflow_designer"), diff --git a/src/govoplan_views/backend/router.py b/src/govoplan_views/backend/router.py index 3736aea..3e8290e 100644 --- a/src/govoplan_views/backend/router.py +++ b/src/govoplan_views/backend/router.py @@ -11,8 +11,10 @@ from govoplan_core.core.policy import ( ViewGovernanceRequest, view_governance_policy, ) +from govoplan_core.core.module_entitlements import tenant_module_entitlement_state from govoplan_core.core.views import VIEW_SURFACE_CONTRACT_VERSION, ViewSurface from govoplan_core.db.session import get_session +from govoplan_core.tenancy.scope import Tenant from govoplan_views.backend.manifest import ( ASSIGNMENT_READ_SCOPE, ASSIGNMENT_WRITE_SCOPE, @@ -82,8 +84,31 @@ from govoplan_views.backend.service import ( router = APIRouter(prefix="/views", tags=["views"]) -def _catalogue() -> tuple[ViewSurface, ...]: - return get_registry().view_surfaces() +def _catalogue( + session: Session | None = None, + principal: ApiPrincipal | None = None, +) -> tuple[ViewSurface, ...]: + registry = get_registry() + surfaces = registry.view_surfaces() + if session is None or principal is None or principal.tenant_id is None: + return surfaces + + tenant = session.get(Tenant, principal.tenant_id) + 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, + ) + effective_modules = set(entitlement.effective_modules) + return tuple( + surface for surface in surfaces if surface.module_id in effective_modules + ) def _view_governance_policy(): @@ -118,7 +143,9 @@ def _enforce_view_policy_action( ), view_id=view_id, candidate_view_ids=(view_id,) if view_id is not None else (), - candidate_surface_ids=tuple(surface.id for surface in _catalogue()), + candidate_surface_ids=tuple( + surface.id for surface in _catalogue(session, principal) + ), requested_surface_ids=surface_ids, ), ) @@ -508,6 +535,7 @@ def _effective_response(state: EffectiveViewState) -> EffectiveViewResponse: def _definition_response( session: Session, + principal: ApiPrincipal, definition, *, readonly: bool, @@ -517,7 +545,7 @@ def _definition_response( session, definition, readonly=readonly, - catalogue=_catalogue(), + catalogue=_catalogue(session, principal), ) ) @@ -538,7 +566,7 @@ def api_effective_view( tenant_id=principal.tenant_id, account_id=principal.account_id, group_ids=principal.group_ids, - catalogue=_catalogue(), + catalogue=_catalogue(session, principal), governance_policy=_view_governance_policy(), ) ) @@ -561,7 +589,7 @@ def api_workflow_effective_view( tenant_id=principal.tenant_id, account_id=principal.account_id, group_ids=principal.group_ids, - catalogue=_catalogue(), + catalogue=_catalogue(session, principal), workflow_view_id=payload.view_id, workflow_revision_id=payload.revision_id, workflow_surface_ids=payload.visible_surface_ids, @@ -584,7 +612,7 @@ def api_select_view( account_id=principal.account_id, group_ids=principal.group_ids, view_id=payload.view_id, - catalogue=_catalogue(), + catalogue=_catalogue(session, principal), governance_policy=_view_governance_policy(), ) _audit( @@ -604,6 +632,7 @@ def api_select_view( @router.get("/surfaces", response_model=ViewSurfaceCatalogueResponse) def api_view_surfaces( + session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> ViewSurfaceCatalogueResponse: _require_any_scope( @@ -641,7 +670,7 @@ def api_view_surfaces( required=surface.required, required_for_locked_view=surface.id in lockout_ids, ) - for surface in _catalogue() + for surface in _catalogue(session, principal) ], ) @@ -670,6 +699,7 @@ def api_list_definitions( definitions=[ _definition_response( session, + principal, definition, readonly=( (scope_type == "tenant" and definition.scope_type == "system") @@ -721,7 +751,7 @@ def api_create_definition( name=payload.name, description=payload.description, visible_surface_ids=payload.visible_surface_ids, - catalogue=_catalogue(), + catalogue=_catalogue(session, principal), actor_id=_actor_id(principal), ) _audit( @@ -733,7 +763,9 @@ def api_create_definition( details={"scope_type": definition.scope_type}, ) session.commit() - return _definition_response(session, definition, readonly=False) + return _definition_response( + session, principal, definition, readonly=False + ) except ViewsError as exc: session.rollback() raise _http_error(exc) from exc @@ -785,7 +817,9 @@ def api_update_definition( details={"fields": sorted(payload.model_fields_set)}, ) session.commit() - return _definition_response(session, definition, readonly=False) + return _definition_response( + session, principal, definition, readonly=False + ) except ViewsError as exc: session.rollback() raise _http_error(exc) from exc @@ -856,7 +890,7 @@ def api_create_revision( session, definition, visible_surface_ids=payload.visible_surface_ids, - catalogue=_catalogue(), + catalogue=_catalogue(session, principal), actor_id=_actor_id(principal), ) _audit( @@ -871,7 +905,9 @@ def api_create_revision( }, ) session.commit() - return _definition_response(session, definition, readonly=False) + return _definition_response( + session, principal, definition, readonly=False + ) except ViewsError as exc: session.rollback() raise _http_error(exc) from exc @@ -915,7 +951,7 @@ def api_publish_revision( session, definition, revision, - catalogue=_catalogue(), + catalogue=_catalogue(session, principal), actor_id=_actor_id(principal), ) _audit( @@ -927,7 +963,9 @@ def api_publish_revision( details={"revision": revision.revision, "revision_id": revision.id}, ) session.commit() - return _definition_response(session, definition, readonly=False) + return _definition_response( + session, principal, definition, readonly=False + ) except ViewsError as exc: session.rollback() raise _http_error(exc) from exc @@ -975,7 +1013,9 @@ def api_archive_definition( details={}, ) session.commit() - return _definition_response(session, definition, readonly=False) + return _definition_response( + session, principal, definition, readonly=False + ) except ViewsError as exc: session.rollback() raise _http_error(exc) from exc @@ -1089,7 +1129,7 @@ def api_create_assignment( priority=payload.priority, is_active=payload.is_active, metadata=payload.metadata, - catalogue=_catalogue(), + catalogue=_catalogue(session, principal), actor_id=_actor_id(principal), ) _audit( @@ -1146,7 +1186,7 @@ def api_update_assignment( session, assignment, updates=payload.model_dump(exclude_unset=True), - catalogue=_catalogue(), + catalogue=_catalogue(session, principal), actor_id=_actor_id(principal), ) _audit( diff --git a/tests/test_authorization.py b/tests/test_authorization.py index 391826f..e991992 100644 --- a/tests/test_authorization.py +++ b/tests/test_authorization.py @@ -1,12 +1,17 @@ from __future__ import annotations import unittest +from types import SimpleNamespace +from unittest.mock import patch from fastapi import HTTPException from govoplan_core.auth import ApiPrincipal from govoplan_core.core.access import PrincipalRef +from govoplan_core.core.modules import ModuleManifest +from govoplan_core.core.views import ViewSurface from govoplan_views.backend.router import ( + _catalogue, _require_definition_read, _require_definition_write, ) @@ -59,6 +64,54 @@ class ViewDefinitionAuthorizationTests(unittest.TestCase): _require_definition_write(actor, "group", "group-2") _require_definition_write(actor, "user", "account-2") + def test_catalogue_excludes_surfaces_of_tenant_unavailable_modules(self) -> None: + actor = principal(scopes=("views:definition:read",)) + registry = SimpleNamespace( + manifests=lambda: ( + ModuleManifest(id="access", name="Access", version="1"), + ModuleManifest(id="views", name="Views", version="1"), + ModuleManifest(id="files", name="Files", version="1"), + ), + view_surfaces=lambda: ( + ViewSurface( + id="views.module", + module_id="views", + kind="module", + label="Views", + ), + ViewSurface( + id="files.module", + module_id="files", + kind="module", + label="Files", + ), + ), + ) + tenant = SimpleNamespace( + settings={ + "module_entitlements": { + "schema_version": 1, + "revision": 1, + "system_policy": { + "available_modules": ["access", "views"], + "forced_modules": ["access"], + }, + "tenant_selection": { + "enabled_modules": ["views"], + }, + } + } + ) + session = SimpleNamespace(get=lambda model, tenant_id: tenant) + + with patch( + "govoplan_views.backend.router.get_registry", + return_value=registry, + ): + catalogue = _catalogue(session, actor) + + self.assertEqual(["views.module"], [surface.id for surface in catalogue]) + if __name__ == "__main__": unittest.main()