Enforce tenant module availability in Views

This commit is contained in:
2026-08-04 08:21:50 +02:00
parent 4da8282234
commit f271ca2b55
4 changed files with 123 additions and 20 deletions
+7
View File
@@ -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 administration surfaces needed to inspect and change the assignment, preventing
administrators from locking an installation out of its own configuration. 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>.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 ## Administration
The module contributes **Views** sections to system and tenant administration. The module contributes **Views** sections to system and tenant administration.
+5 -2
View File
@@ -349,8 +349,11 @@ manifest = ModuleManifest(
"and system assignments. Pinning preserves one published revision; an " "and system assignments. Pinning preserves one published revision; an "
"unpinned assignment follows later publications. Required Views must keep " "unpinned assignment follows later publications. Required Views must keep "
"the selector and administration escape surfaces. Hiding a surface never " "the selector and administration escape surfaces. Hiding a surface never "
"grants or revokes authorization, and inherited definitions or assignments " "grants or revokes authorization. The surface catalogue is constrained by "
"must be changed in their owning scope." "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",), documentation_types=("admin",),
audience=("administrator", "power_user", "workflow_designer"), audience=("administrator", "power_user", "workflow_designer"),
+58 -18
View File
@@ -11,8 +11,10 @@ from govoplan_core.core.policy import (
ViewGovernanceRequest, ViewGovernanceRequest,
view_governance_policy, 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.core.views import VIEW_SURFACE_CONTRACT_VERSION, ViewSurface
from govoplan_core.db.session import get_session from govoplan_core.db.session import get_session
from govoplan_core.tenancy.scope import Tenant
from govoplan_views.backend.manifest import ( from govoplan_views.backend.manifest import (
ASSIGNMENT_READ_SCOPE, ASSIGNMENT_READ_SCOPE,
ASSIGNMENT_WRITE_SCOPE, ASSIGNMENT_WRITE_SCOPE,
@@ -82,8 +84,31 @@ from govoplan_views.backend.service import (
router = APIRouter(prefix="/views", tags=["views"]) router = APIRouter(prefix="/views", tags=["views"])
def _catalogue() -> tuple[ViewSurface, ...]: def _catalogue(
return get_registry().view_surfaces() 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(): def _view_governance_policy():
@@ -118,7 +143,9 @@ def _enforce_view_policy_action(
), ),
view_id=view_id, view_id=view_id,
candidate_view_ids=(view_id,) if view_id is not None else (), 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, requested_surface_ids=surface_ids,
), ),
) )
@@ -508,6 +535,7 @@ def _effective_response(state: EffectiveViewState) -> EffectiveViewResponse:
def _definition_response( def _definition_response(
session: Session, session: Session,
principal: ApiPrincipal,
definition, definition,
*, *,
readonly: bool, readonly: bool,
@@ -517,7 +545,7 @@ def _definition_response(
session, session,
definition, definition,
readonly=readonly, readonly=readonly,
catalogue=_catalogue(), catalogue=_catalogue(session, principal),
) )
) )
@@ -538,7 +566,7 @@ def api_effective_view(
tenant_id=principal.tenant_id, tenant_id=principal.tenant_id,
account_id=principal.account_id, account_id=principal.account_id,
group_ids=principal.group_ids, group_ids=principal.group_ids,
catalogue=_catalogue(), catalogue=_catalogue(session, principal),
governance_policy=_view_governance_policy(), governance_policy=_view_governance_policy(),
) )
) )
@@ -561,7 +589,7 @@ def api_workflow_effective_view(
tenant_id=principal.tenant_id, tenant_id=principal.tenant_id,
account_id=principal.account_id, account_id=principal.account_id,
group_ids=principal.group_ids, group_ids=principal.group_ids,
catalogue=_catalogue(), catalogue=_catalogue(session, principal),
workflow_view_id=payload.view_id, workflow_view_id=payload.view_id,
workflow_revision_id=payload.revision_id, workflow_revision_id=payload.revision_id,
workflow_surface_ids=payload.visible_surface_ids, workflow_surface_ids=payload.visible_surface_ids,
@@ -584,7 +612,7 @@ def api_select_view(
account_id=principal.account_id, account_id=principal.account_id,
group_ids=principal.group_ids, group_ids=principal.group_ids,
view_id=payload.view_id, view_id=payload.view_id,
catalogue=_catalogue(), catalogue=_catalogue(session, principal),
governance_policy=_view_governance_policy(), governance_policy=_view_governance_policy(),
) )
_audit( _audit(
@@ -604,6 +632,7 @@ def api_select_view(
@router.get("/surfaces", response_model=ViewSurfaceCatalogueResponse) @router.get("/surfaces", response_model=ViewSurfaceCatalogueResponse)
def api_view_surfaces( def api_view_surfaces(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
) -> ViewSurfaceCatalogueResponse: ) -> ViewSurfaceCatalogueResponse:
_require_any_scope( _require_any_scope(
@@ -641,7 +670,7 @@ def api_view_surfaces(
required=surface.required, required=surface.required,
required_for_locked_view=surface.id in lockout_ids, 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=[ definitions=[
_definition_response( _definition_response(
session, session,
principal,
definition, definition,
readonly=( readonly=(
(scope_type == "tenant" and definition.scope_type == "system") (scope_type == "tenant" and definition.scope_type == "system")
@@ -721,7 +751,7 @@ def api_create_definition(
name=payload.name, name=payload.name,
description=payload.description, description=payload.description,
visible_surface_ids=payload.visible_surface_ids, visible_surface_ids=payload.visible_surface_ids,
catalogue=_catalogue(), catalogue=_catalogue(session, principal),
actor_id=_actor_id(principal), actor_id=_actor_id(principal),
) )
_audit( _audit(
@@ -733,7 +763,9 @@ def api_create_definition(
details={"scope_type": definition.scope_type}, details={"scope_type": definition.scope_type},
) )
session.commit() session.commit()
return _definition_response(session, definition, readonly=False) return _definition_response(
session, principal, definition, readonly=False
)
except ViewsError as exc: except ViewsError as exc:
session.rollback() session.rollback()
raise _http_error(exc) from exc raise _http_error(exc) from exc
@@ -785,7 +817,9 @@ def api_update_definition(
details={"fields": sorted(payload.model_fields_set)}, details={"fields": sorted(payload.model_fields_set)},
) )
session.commit() session.commit()
return _definition_response(session, definition, readonly=False) return _definition_response(
session, principal, definition, readonly=False
)
except ViewsError as exc: except ViewsError as exc:
session.rollback() session.rollback()
raise _http_error(exc) from exc raise _http_error(exc) from exc
@@ -856,7 +890,7 @@ def api_create_revision(
session, session,
definition, definition,
visible_surface_ids=payload.visible_surface_ids, visible_surface_ids=payload.visible_surface_ids,
catalogue=_catalogue(), catalogue=_catalogue(session, principal),
actor_id=_actor_id(principal), actor_id=_actor_id(principal),
) )
_audit( _audit(
@@ -871,7 +905,9 @@ def api_create_revision(
}, },
) )
session.commit() session.commit()
return _definition_response(session, definition, readonly=False) return _definition_response(
session, principal, definition, readonly=False
)
except ViewsError as exc: except ViewsError as exc:
session.rollback() session.rollback()
raise _http_error(exc) from exc raise _http_error(exc) from exc
@@ -915,7 +951,7 @@ def api_publish_revision(
session, session,
definition, definition,
revision, revision,
catalogue=_catalogue(), catalogue=_catalogue(session, principal),
actor_id=_actor_id(principal), actor_id=_actor_id(principal),
) )
_audit( _audit(
@@ -927,7 +963,9 @@ def api_publish_revision(
details={"revision": revision.revision, "revision_id": revision.id}, details={"revision": revision.revision, "revision_id": revision.id},
) )
session.commit() session.commit()
return _definition_response(session, definition, readonly=False) return _definition_response(
session, principal, definition, readonly=False
)
except ViewsError as exc: except ViewsError as exc:
session.rollback() session.rollback()
raise _http_error(exc) from exc raise _http_error(exc) from exc
@@ -975,7 +1013,9 @@ def api_archive_definition(
details={}, details={},
) )
session.commit() session.commit()
return _definition_response(session, definition, readonly=False) return _definition_response(
session, principal, definition, readonly=False
)
except ViewsError as exc: except ViewsError as exc:
session.rollback() session.rollback()
raise _http_error(exc) from exc raise _http_error(exc) from exc
@@ -1089,7 +1129,7 @@ def api_create_assignment(
priority=payload.priority, priority=payload.priority,
is_active=payload.is_active, is_active=payload.is_active,
metadata=payload.metadata, metadata=payload.metadata,
catalogue=_catalogue(), catalogue=_catalogue(session, principal),
actor_id=_actor_id(principal), actor_id=_actor_id(principal),
) )
_audit( _audit(
@@ -1146,7 +1186,7 @@ def api_update_assignment(
session, session,
assignment, assignment,
updates=payload.model_dump(exclude_unset=True), updates=payload.model_dump(exclude_unset=True),
catalogue=_catalogue(), catalogue=_catalogue(session, principal),
actor_id=_actor_id(principal), actor_id=_actor_id(principal),
) )
_audit( _audit(
+53
View File
@@ -1,12 +1,17 @@
from __future__ import annotations from __future__ import annotations
import unittest import unittest
from types import SimpleNamespace
from unittest.mock import patch
from fastapi import HTTPException from fastapi import HTTPException
from govoplan_core.auth import ApiPrincipal from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef 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 ( from govoplan_views.backend.router import (
_catalogue,
_require_definition_read, _require_definition_read,
_require_definition_write, _require_definition_write,
) )
@@ -59,6 +64,54 @@ class ViewDefinitionAuthorizationTests(unittest.TestCase):
_require_definition_write(actor, "group", "group-2") _require_definition_write(actor, "group", "group-2")
_require_definition_write(actor, "user", "account-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__": if __name__ == "__main__":
unittest.main() unittest.main()