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", ]