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
|
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.
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user