Enforce tenant module availability in Views
This commit is contained in:
@@ -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>.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.
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user