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
+5 -2
View File
@@ -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"),
+58 -18
View File
@@ -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(