feat: implement governed service directory
This commit is contained in:
@@ -0,0 +1,501 @@
|
||||
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",
|
||||
]
|
||||
Reference in New Issue
Block a user