502 lines
17 KiB
Python
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",
|
|
]
|