Files
govoplan-portal/src/govoplan_portal/backend/service_directory.py
T

502 lines
17 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Literal
from govoplan_core.core.access import (
AccessSemanticDirectory,
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY,
PrincipalRef,
)
from govoplan_core.core.institutional import (
CAPABILITY_SERVICE_AVAILABILITY,
CAPABILITY_SERVICE_DEFINITIONS,
EvidenceReference,
InstitutionalContextError,
InstitutionalReference,
ServiceBinding,
ServiceAvailabilityAssessment,
ServiceAvailabilityEvaluator,
ServiceDefinition,
ServiceDefinitionProvider,
ServiceLaunchRequest,
ServiceLaunchResult,
ServiceLauncher,
service_launch_capability,
)
CAPABILITY_PORTAL_SERVICE_DIRECTORY = "portal.service_directory"
ServiceDiscoveryState = Literal["available", "unavailable", "hidden"]
@dataclass(frozen=True, slots=True)
class PortalServiceEntry:
definition: ServiceDefinition
state: ServiceDiscoveryState
reason_codes: tuple[str, ...] = ()
entry_binding: ServiceBinding | None = None
availability_evidence: tuple[EvidenceReference, ...] = ()
@property
def discoverable(self) -> bool:
return self.state != "hidden"
@property
def available(self) -> bool:
return self.state == "available"
def to_dict(self) -> dict[str, object]:
return {
"definition": self.definition.to_dict(include_inspection=False),
"state": self.state,
"reason_codes": list(self.reason_codes),
"entry_binding": (
self.entry_binding.to_dict() if self.entry_binding else None
),
"availability_evidence": [
item.to_dict(include_inspection=False)
for item in self.availability_evidence
],
}
class PortalServiceDirectory:
"""Role-aware projection over a provider-owned service definition catalogue."""
def __init__(self, registry: object | None = None) -> None:
self._registry = registry
def list_entries(
self,
session: object,
principal: object,
*,
tenant_id: str,
effective_at: datetime,
audiences: tuple[str, ...] | None = None,
query: str = "",
limit: int = 100,
include_unavailable: bool = True,
) -> tuple[PortalServiceEntry, ...]:
if not tenant_id.strip():
raise InstitutionalContextError("Portal service tenant id is required.")
if not 1 <= limit <= 200:
raise InstitutionalContextError(
"Portal service directory limit must be between 1 and 200."
)
effective_audiences = (
audiences
if audiences is not None
else principal_audiences(
self._registry,
session,
principal,
tenant_id=tenant_id,
effective_at=effective_at,
)
)
provider = _capability(self._registry, CAPABILITY_SERVICE_DEFINITIONS)
if not isinstance(provider, ServiceDefinitionProvider):
return ()
definitions = provider.list_service_definitions(
session,
principal,
tenant_id=tenant_id,
query=query,
limit=limit,
)
evaluator = _capability(
self._registry,
CAPABILITY_SERVICE_AVAILABILITY,
)
entries: list[PortalServiceEntry] = []
for definition in definitions[:limit]:
assessment = None
if isinstance(evaluator, ServiceAvailabilityEvaluator):
try:
assessment = evaluator.evaluate_service_availability(
session,
principal,
definition=definition,
effective_at=effective_at,
)
except Exception: # noqa: BLE001 - isolate optional evaluators.
assessment = ServiceAvailabilityAssessment(
requirement_states={},
reason_codes=("service.availability.evaluator_failed",),
)
entries.append(
self.project(
definition,
tenant_id=tenant_id,
effective_at=effective_at,
audiences=effective_audiences,
assessment=assessment,
)
)
return tuple(
entry
for entry in entries
if entry.discoverable
and (include_unavailable or entry.available)
)
def get_entry(
self,
session: object,
principal: object,
*,
reference: InstitutionalReference,
effective_at: datetime,
) -> PortalServiceEntry | None:
provider = _capability(self._registry, CAPABILITY_SERVICE_DEFINITIONS)
if not isinstance(provider, ServiceDefinitionProvider):
return None
definition = provider.get_service_definition(
session,
principal,
reference=reference,
effective_at=effective_at,
)
if definition is None:
return None
if definition.reference != reference:
raise InstitutionalContextError(
"Service provider did not return the requested exact revision."
)
evaluator = _capability(
self._registry,
CAPABILITY_SERVICE_AVAILABILITY,
)
assessment = None
if isinstance(evaluator, ServiceAvailabilityEvaluator):
try:
assessment = evaluator.evaluate_service_availability(
session,
principal,
definition=definition,
effective_at=effective_at,
)
except Exception: # noqa: BLE001 - optional evaluator isolation.
assessment = ServiceAvailabilityAssessment(
requirement_states={},
reason_codes=("service.availability.evaluator_failed",),
)
return self.project(
definition,
tenant_id=reference.tenant_id,
effective_at=effective_at,
audiences=principal_audiences(
self._registry,
session,
principal,
tenant_id=reference.tenant_id,
effective_at=effective_at,
),
assessment=assessment,
)
def launch_service(
self,
session: object,
principal: object,
*,
reference: InstitutionalReference,
requested_at: datetime,
idempotency_key: str,
parameters: dict[str, object],
) -> ServiceLaunchResult:
entry = self.get_entry(
session,
principal,
reference=reference,
effective_at=requested_at,
)
if entry is None or not entry.discoverable:
raise LookupError("Service not found.")
if not entry.available:
raise PortalServiceLaunchError(
"Service is currently unavailable: "
+ ", ".join(entry.reason_codes)
)
binding = entry.entry_binding
if binding is None:
raise PortalServiceLaunchError(
"Service has no supported entry binding."
)
request = ServiceLaunchRequest(
service_ref=entry.definition.reference,
binding=binding,
idempotency_key=idempotency_key,
requested_at=requested_at,
parameters=parameters,
)
if binding.kind in {"url", "external"}:
return ServiceLaunchResult(
service_ref=entry.definition.reference,
binding=binding,
state="redirect",
href=binding.reference,
metadata={},
)
capability_name = service_launch_capability(binding.kind)
launcher = _capability(self._registry, capability_name)
if not isinstance(launcher, ServiceLauncher):
raise PortalServiceLaunchError(
f"Service launcher is unavailable: {capability_name}."
)
result = launcher.launch_service(
session,
principal,
definition=entry.definition,
request=request,
)
if (
result.service_ref != entry.definition.reference
or result.binding != binding
):
raise InstitutionalContextError(
"Service launcher returned a result for another definition or binding."
)
return result
def project(
self,
definition: ServiceDefinition,
*,
tenant_id: str,
effective_at: datetime,
audiences: tuple[str, ...] = (),
assessment: ServiceAvailabilityAssessment | None = None,
) -> PortalServiceEntry:
if definition.reference.tenant_id != tenant_id:
raise InstitutionalContextError(
"Portal cannot project a service from another tenant."
)
if assessment is not None and any(
item.tenant_id != tenant_id for item in assessment.evidence
):
raise InstitutionalContextError(
"Service availability evidence belongs to another tenant."
)
hidden_reasons: list[str] = []
unavailable_reasons: list[str] = []
if definition.publication_state in {"draft", "retired"}:
hidden_reasons.append(
f"service.publication.{definition.publication_state}"
)
elif definition.publication_state == "suspended":
unavailable_reasons.append("service.publication.suspended")
if not definition.temporal.effective_at(effective_at):
hidden_reasons.append("service.outside_effective_interval")
if not set(audiences).intersection(definition.audience):
hidden_reasons.append("service.audience.not_applicable")
for binding in definition.bindings:
if not binding.required:
continue
if binding.kind == "module" and not _has_module(
self._registry, binding.reference
):
unavailable_reasons.append(
f"service.required_module.missing:{binding.reference}"
)
elif binding.kind == "capability" and _capability(
self._registry, binding.reference
) is None:
unavailable_reasons.append(
f"service.required_capability.missing:{binding.reference}"
)
assessment_states = (
assessment.requirement_states if assessment is not None else {}
)
for requirement in definition.availability_requirements:
if requirement.kind == "module":
resolved: bool | None = _has_module(
self._registry,
requirement.reference,
)
elif requirement.kind == "capability":
resolved = (
_capability(self._registry, requirement.reference) is not None
)
elif requirement.kind == "audience":
resolved = requirement.reference in audiences
else:
resolved = assessment_states.get(requirement.key)
if resolved is True:
continue
reason = (
f"service.requirement.failed:{requirement.key}"
if resolved is False
else f"service.requirement.unknown:{requirement.key}"
)
reasons = (
hidden_reasons
if requirement.failure_state == "hidden"
else unavailable_reasons
)
reasons.append(reason)
if requirement.explanation_ref:
reasons.append(
f"service.explanation:{requirement.explanation_ref}"
)
if assessment is not None and (
hidden_reasons or unavailable_reasons
):
unavailable_reasons.extend(assessment.reason_codes)
if definition.availability_explanation_ref and unavailable_reasons:
unavailable_reasons.append(
f"service.explanation:{definition.availability_explanation_ref}"
)
entry_binding = next(
(
binding
for binding in definition.bindings
if binding.kind in {"form", "case", "workflow", "external", "url"}
),
None,
)
if entry_binding is not None and entry_binding.kind in {
"form",
"case",
"workflow",
}:
capability_name = service_launch_capability(entry_binding.kind)
if _capability(self._registry, capability_name) is None:
unavailable_reasons.append(
f"service.launcher.missing:{capability_name}"
)
state: ServiceDiscoveryState = (
"hidden"
if hidden_reasons
else "unavailable"
if unavailable_reasons
else "available"
)
return PortalServiceEntry(
definition=definition,
state=state,
reason_codes=tuple(
dict.fromkeys((*hidden_reasons, *unavailable_reasons))
),
entry_binding=entry_binding,
availability_evidence=(assessment.evidence if assessment else ()),
)
class PortalServiceLaunchError(RuntimeError):
pass
def principal_audiences(
registry: object | None,
_session: object,
principal: object,
*,
tenant_id: str,
effective_at: datetime,
) -> tuple[str, ...]:
"""Build bounded audience tokens from trusted principal state.
Definitions can target generic authenticated users and stable account,
identity, group, role, organization-unit, or function identifiers. Function
slugs are included only after resolving an active tenant-bound assignment.
Provider failures fail closed to the tokens already present on the signed-in
principal.
"""
ref = _principal_ref(principal)
if ref is None or ref.tenant_id != tenant_id:
return ("public",)
tokens = {"public", "authenticated", f"account:{ref.account_id}"}
if ref.identity_id:
tokens.add(f"identity:{ref.identity_id}")
tokens.update(f"group:{item}" for item in ref.group_ids)
tokens.update(f"role:{item}" for item in ref.role_ids)
tokens.update(
f"function-assignment:{item}" for item in ref.function_assignment_ids
)
directory = _capability(registry, CAPABILITY_ACCESS_SEMANTIC_DIRECTORY)
if not isinstance(directory, AccessSemanticDirectory):
return tuple(sorted(tokens))
for assignment_id in sorted(ref.function_assignment_ids):
try:
assignment = directory.get_function_assignment(assignment_id)
if (
assignment is None
or assignment.tenant_id != tenant_id
or assignment.account_id != ref.account_id
or assignment.status != "active"
or (
assignment.valid_from is not None
and effective_at < assignment.valid_from
)
or (
assignment.valid_until is not None
and effective_at >= assignment.valid_until
)
):
continue
function = directory.get_function(assignment.function_id)
if (
function is None
or function.tenant_id != tenant_id
or function.status != "active"
):
continue
except Exception: # noqa: BLE001 - optional directory failures fail closed.
continue
tokens.update(
{
f"function:{function.id}",
f"function:{function.slug}",
f"organization-unit:{assignment.organization_unit_id}",
}
)
return tuple(sorted(tokens))
def _principal_ref(principal: object) -> PrincipalRef | None:
if isinstance(principal, PrincipalRef):
return principal
converter = getattr(principal, "to_platform_principal", None)
if not callable(converter):
return None
try:
value = converter()
except Exception: # noqa: BLE001 - untrusted provider object.
return None
return value if isinstance(value, PrincipalRef) else None
def _has_module(registry: object | None, module_id: str) -> bool:
return bool(
registry is not None
and hasattr(registry, "has")
and registry.has(module_id)
)
def _capability(registry: object | None, name: str) -> object | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(name)
):
return None
return registry.capability(name)
__all__ = [
"CAPABILITY_PORTAL_SERVICE_DIRECTORY",
"PortalServiceDirectory",
"PortalServiceEntry",
"PortalServiceLaunchError",
"ServiceDiscoveryState",
"principal_audiences",
]