feat: implement governed service directory
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
"""GovOPlaN Portal module."""
|
||||
|
||||
from govoplan_portal.backend.manifest import get_manifest
|
||||
|
||||
__all__ = ["get_manifest"]
|
||||
@@ -0,0 +1 @@
|
||||
"""Portal backend contracts."""
|
||||
@@ -0,0 +1,220 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.institutional import (
|
||||
CAPABILITY_SERVICE_AVAILABILITY,
|
||||
CAPABILITY_SERVICE_DEFINITIONS,
|
||||
service_launch_capability,
|
||||
)
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityDocumentation,
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
FrontendRoute,
|
||||
ModuleContext,
|
||||
ModuleInterfaceProvider,
|
||||
ModuleInterfaceRequirement,
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.provider_governance import (
|
||||
ModuleArchitectureDeclaration,
|
||||
ModuleArchitectureDocumentation,
|
||||
ModuleMaturityEvidence,
|
||||
)
|
||||
from govoplan_portal.backend.service_directory import (
|
||||
CAPABILITY_PORTAL_SERVICE_DIRECTORY,
|
||||
PortalServiceDirectory,
|
||||
)
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
|
||||
|
||||
MODULE_ID = "portal"
|
||||
MODULE_VERSION = "0.1.8"
|
||||
READ_SCOPE = "portal:service:read"
|
||||
SERVICE_LAUNCH_CAPABILITIES = tuple(
|
||||
service_launch_capability(kind) for kind in ("case", "form", "workflow")
|
||||
)
|
||||
|
||||
|
||||
def _service_directory(context: ModuleContext) -> PortalServiceDirectory:
|
||||
return PortalServiceDirectory(context.registry)
|
||||
|
||||
|
||||
def _router(context: ModuleContext):
|
||||
from govoplan_portal.backend.router import configure_registry, router
|
||||
|
||||
configure_registry(context.registry)
|
||||
return router
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id=MODULE_ID,
|
||||
name="Portal",
|
||||
version=MODULE_VERSION,
|
||||
optional_dependencies=(
|
||||
"access",
|
||||
"services",
|
||||
"cases",
|
||||
"forms",
|
||||
"forms_runtime",
|
||||
"workflow_engine",
|
||||
),
|
||||
optional_capabilities=(
|
||||
CAPABILITY_SERVICE_DEFINITIONS,
|
||||
CAPABILITY_SERVICE_AVAILABILITY,
|
||||
*SERVICE_LAUNCH_CAPABILITIES,
|
||||
),
|
||||
permissions=(
|
||||
PermissionDefinition(
|
||||
scope=READ_SCOPE,
|
||||
label="View service directory",
|
||||
description="Discover services available to the current account and function assignments.",
|
||||
category="Portal",
|
||||
level="tenant",
|
||||
module_id=MODULE_ID,
|
||||
resource="service",
|
||||
action="read",
|
||||
),
|
||||
),
|
||||
role_templates=(
|
||||
RoleTemplate(
|
||||
slug="portal_user",
|
||||
name="Portal user",
|
||||
description="Discover and enter available institutional services.",
|
||||
permissions=(READ_SCOPE,),
|
||||
default_authenticated=True,
|
||||
),
|
||||
),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name="portal.service_directory", version="0.1.0"),
|
||||
),
|
||||
requires_interfaces=(
|
||||
ModuleInterfaceRequirement(name="services.definition", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
|
||||
ModuleInterfaceRequirement(name="services.availability", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
|
||||
*(
|
||||
ModuleInterfaceRequirement(
|
||||
name=capability,
|
||||
version_min="0.1.0",
|
||||
version_max_exclusive="0.2.0",
|
||||
optional=True,
|
||||
)
|
||||
for capability in SERVICE_LAUNCH_CAPABILITIES
|
||||
),
|
||||
),
|
||||
capability_factories={
|
||||
CAPABILITY_PORTAL_SERVICE_DIRECTORY: _service_directory,
|
||||
},
|
||||
route_factory=_router,
|
||||
nav_items=(
|
||||
NavItem(
|
||||
path="/portal",
|
||||
label="Services",
|
||||
icon="landmark",
|
||||
required_any=(READ_SCOPE,),
|
||||
order=25,
|
||||
),
|
||||
),
|
||||
frontend=FrontendModule(
|
||||
module_id=MODULE_ID,
|
||||
package_name="@govoplan/portal-webui",
|
||||
routes=(
|
||||
FrontendRoute(
|
||||
path="/portal",
|
||||
component="PortalPage",
|
||||
required_any=(READ_SCOPE,),
|
||||
order=25,
|
||||
),
|
||||
),
|
||||
nav_items=(
|
||||
NavItem(
|
||||
path="/portal",
|
||||
label="Services",
|
||||
icon="landmark",
|
||||
required_any=(READ_SCOPE,),
|
||||
order=25,
|
||||
),
|
||||
),
|
||||
view_surfaces=(
|
||||
ViewSurface(
|
||||
id="portal.navigation",
|
||||
module_id=MODULE_ID,
|
||||
kind="navigation",
|
||||
label="Services navigation",
|
||||
order=10,
|
||||
),
|
||||
ViewSurface(
|
||||
id="portal.directory",
|
||||
module_id=MODULE_ID,
|
||||
kind="route",
|
||||
label="Service directory",
|
||||
order=20,
|
||||
),
|
||||
),
|
||||
),
|
||||
capability_documentation={
|
||||
CAPABILITY_PORTAL_SERVICE_DIRECTORY: CapabilityDocumentation(
|
||||
label="Portal service directory",
|
||||
summary="Projects governed service definitions into role-aware availability entries.",
|
||||
contract_version="0.1.0",
|
||||
),
|
||||
},
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="portal.service-directory",
|
||||
title="Service directory",
|
||||
summary="Find services available in the configured institution and understand relevant availability limits.",
|
||||
body=(
|
||||
"Portal presents provider-owned, versioned service definitions. "
|
||||
"Published services may be available, unavailable with a reason, "
|
||||
"or undiscoverable when they do not apply to the current audience. "
|
||||
"Opening a service re-evaluates that exact revision and delegates case, "
|
||||
"form, or workflow startup to an installed owner capability."
|
||||
),
|
||||
layer="available",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "operator", "module_admin"),
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Service directory architecture",
|
||||
href="govoplan-portal/docs/SERVICE_DIRECTORY_CONCEPT.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
architecture=ModuleArchitectureDeclaration(
|
||||
layer="communication_participation",
|
||||
kind="domain",
|
||||
maturity="vertical_slice",
|
||||
evidence=(
|
||||
ModuleMaturityEvidence(
|
||||
kind="test",
|
||||
reference="tests/test_service_directory.py",
|
||||
summary="Proves provider-neutral service discovery and explained availability.",
|
||||
),
|
||||
ModuleMaturityEvidence(
|
||||
kind="documentation",
|
||||
reference="docs/SERVICE_DIRECTORY_CONCEPT.md",
|
||||
summary="Defines Portal presentation and Services ownership boundaries.",
|
||||
),
|
||||
),
|
||||
known_limits=(
|
||||
"Portal does not persist service definitions; the Services provider remains authoritative.",
|
||||
"Case, Forms Runtime, and Workflow Engine own launch effects. Portal keeps entries unavailable whenever the selected owner capability is absent.",
|
||||
),
|
||||
owned_concepts=("service discovery", "service presentation", "channel entry"),
|
||||
non_owned_concepts=("institutional service definition", "case lifecycle"),
|
||||
reference_packages=("product.service-to-decision",),
|
||||
documentation=ModuleArchitectureDocumentation(
|
||||
security=("docs/SERVICE_DIRECTORY_CONCEPT.md",),
|
||||
operations=("docs/SERVICE_DIRECTORY_CONCEPT.md",),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_manifest() -> ModuleManifest:
|
||||
return manifest
|
||||
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
||||
from govoplan_core.core.institutional import (
|
||||
InstitutionalContextError,
|
||||
InstitutionalReference,
|
||||
)
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_portal.backend.schemas import (
|
||||
PortalServiceLaunchRequest,
|
||||
PortalServiceLaunchResponse,
|
||||
PortalServiceListResponse,
|
||||
)
|
||||
from govoplan_portal.backend.service_directory import (
|
||||
PortalServiceDirectory,
|
||||
PortalServiceLaunchError,
|
||||
)
|
||||
|
||||
|
||||
READ_SCOPE = "portal:service:read"
|
||||
router = APIRouter(prefix="/portal", tags=["portal"])
|
||||
_registry: object | None = None
|
||||
|
||||
|
||||
def configure_registry(registry: object | None) -> None:
|
||||
global _registry
|
||||
_registry = registry
|
||||
|
||||
|
||||
@router.get("/services", response_model=PortalServiceListResponse)
|
||||
def api_list_portal_services(
|
||||
q: str = Query(default="", max_length=200),
|
||||
include_unavailable: bool = True,
|
||||
effective_at: datetime | None = None,
|
||||
limit: int = Query(default=100, ge=1, le=200),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> PortalServiceListResponse:
|
||||
if not has_scope(principal, READ_SCOPE):
|
||||
raise HTTPException(status_code=403, detail=f"Missing scope: {READ_SCOPE}")
|
||||
observed_at = effective_at or datetime.now(tz=UTC)
|
||||
if observed_at.tzinfo is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Portal service effective_at must include a timezone.",
|
||||
)
|
||||
try:
|
||||
entries = PortalServiceDirectory(_registry).list_entries(
|
||||
session,
|
||||
principal,
|
||||
tenant_id=principal.tenant_id,
|
||||
effective_at=observed_at,
|
||||
query=q.strip(),
|
||||
limit=limit,
|
||||
include_unavailable=include_unavailable,
|
||||
)
|
||||
except InstitutionalContextError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return PortalServiceListResponse(
|
||||
services=[entry.to_dict() for entry in entries]
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/services/{service_id}/launch",
|
||||
response_model=PortalServiceLaunchResponse,
|
||||
)
|
||||
def api_launch_portal_service(
|
||||
service_id: str,
|
||||
payload: PortalServiceLaunchRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> PortalServiceLaunchResponse:
|
||||
if not has_scope(principal, READ_SCOPE):
|
||||
raise HTTPException(status_code=403, detail=f"Missing scope: {READ_SCOPE}")
|
||||
if payload.requested_at.tzinfo is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Portal service launch requested_at must include a timezone.",
|
||||
)
|
||||
reference = InstitutionalReference(
|
||||
kind="service",
|
||||
owner_module="services",
|
||||
object_id=service_id,
|
||||
tenant_id=principal.tenant_id,
|
||||
version=payload.service_version,
|
||||
valid_at=payload.requested_at,
|
||||
)
|
||||
try:
|
||||
result = PortalServiceDirectory(_registry).launch_service(
|
||||
session,
|
||||
principal,
|
||||
reference=reference,
|
||||
requested_at=payload.requested_at,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
parameters=payload.parameters,
|
||||
)
|
||||
session.commit()
|
||||
except LookupError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except PortalServiceLaunchError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except (InstitutionalContextError, ValueError) as exc:
|
||||
session.rollback()
|
||||
status_code = 409 if "conflict" in str(exc).casefold() else 400
|
||||
raise HTTPException(status_code=status_code, detail=str(exc)) from exc
|
||||
return PortalServiceLaunchResponse(**result.to_dict())
|
||||
|
||||
|
||||
__all__ = ["READ_SCOPE", "configure_registry", "router"]
|
||||
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class PortalServiceEntryResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
definition: dict[str, Any]
|
||||
state: str
|
||||
reason_codes: list[str] = Field(default_factory=list)
|
||||
entry_binding: dict[str, Any] | None = None
|
||||
availability_evidence: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PortalServiceListResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
services: list[PortalServiceEntryResponse]
|
||||
|
||||
|
||||
class PortalServiceLaunchRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
service_version: str = Field(min_length=1, max_length=120)
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
requested_at: datetime
|
||||
parameters: dict[str, Any] = Field(default_factory=dict, max_length=100)
|
||||
|
||||
|
||||
class PortalServiceLaunchResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
service_ref: dict[str, Any]
|
||||
binding: dict[str, Any]
|
||||
state: str
|
||||
target_ref: dict[str, Any] | None = None
|
||||
href: str | None = None
|
||||
replayed: bool = False
|
||||
evidence: list[dict[str, Any]] = Field(default_factory=list)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PortalServiceEntryResponse",
|
||||
"PortalServiceLaunchRequest",
|
||||
"PortalServiceLaunchResponse",
|
||||
"PortalServiceListResponse",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
Reference in New Issue
Block a user