Commit 25204011 authored by Sergio Gimenez's avatar Sergio Gimenez
Browse files

feat(fm): receive onPartnerStatusEvent from partners (#26)

FM handed every partner a partnerStatusLink and had no route behind it,
so a partner reporting a federation status change got a 404.

Adds the callback router from ADR-0054 under
/operatorplatform/federation/v1/callbacks/: it requires the
fed-mgmt-notif
scope, resolves azp to an active partner, and answers 404 unless the
federationContextId is an outbound context of that caller. The
onPartnerStatusEvent handler maps OPG.04's FederationStatus onto
federation_contexts.status and is idempotent. Callbacks about zones or
network codes are accepted and logged rather than rejected, since
shared_resource_catalogues does not exist yet.

PartnerAuthenticator now takes the required scope, defaulting to
fed-mgmt,
and the dev realm gains fed-mgmt-notif as an optional scope on each
partner
client. Without it FM's own callback client could not get a token
either,
so notifications had never worked against real Keycloak.
parent a7291e6c
Loading
Loading
Loading
Loading
+38 −10
Original line number Diff line number Diff line
@@ -7,6 +7,16 @@
      "name": "fed-mgmt",
      "protocol": "openid-connect",
      "description": "fed-mgmt"
    },
    {
      "id": "1d0f2129-dc8d-4675-8f44-5acb5154d3f6",
      "name": "fed-mgmt-notif",
      "protocol": "openid-connect",
      "description": "Access to the federation notification APIs (OPG.04 callbacks)",
      "attributes": {
        "include.in.token.scope": "true",
        "display.on.consent.screen": "false"
      }
    }
  ],
  "clients": [
@@ -15,24 +25,42 @@
      "enabled": true,
      "clientAuthenticatorType": "client-secret",
      "secret": "dd7vNwFqjNpYwaghlEwMbw10g0klWDHb",
      "redirectUris": ["http://localhost:8080/*"],
      "redirectUris": [
        "http://localhost:8080/*"
      ],
      "publicClient": false,
      "directAccessGrantsEnabled": true,
      "serviceAccountsEnabled": true,
      "defaultClientScopes": ["fed-mgmt"],
      "webOrigins": ["*"]
      "defaultClientScopes": [
        "fed-mgmt"
      ],
      "webOrigins": [
        "*"
      ],
      "optionalClientScopes": [
        "fed-mgmt-notif"
      ]
    },
    {
      "clientId": "originating-op-2",
      "enabled": true,
      "clientAuthenticatorType": "client-secret",
      "secret": "2mhznERfWclLDuVojY77Lp4Qd2r4e8Ms",
      "redirectUris": ["http://localhost:8080/*"],
      "redirectUris": [
        "http://localhost:8080/*"
      ],
      "publicClient": false,
      "directAccessGrantsEnabled": true,
      "serviceAccountsEnabled": true,
      "defaultClientScopes": ["fed-mgmt"],
      "webOrigins": ["*"]
      "defaultClientScopes": [
        "fed-mgmt"
      ],
      "webOrigins": [
        "*"
      ],
      "optionalClientScopes": [
        "fed-mgmt-notif"
      ]
    }
  ]
}
+34 −0
Original line number Diff line number Diff line
from typing import Annotated

from fastapi import APIRouter, Depends

from federation_manager.api.errors import EWBI_ERROR_RESPONSES
from federation_manager.api.security import get_bearer_token
from federation_manager.application.authentication import NOTIFICATION_SCOPE, PartnerAuthenticator
from federation_manager.application.callbacks import PartnerStatusCallbackService
from federation_manager.contracts.ewbi import PartnerStatusEvent
from federation_manager.dependencies import (
    get_partner_authenticator,
    get_partner_status_callback_service,
)
from federation_manager.domain.ewbi import EWBI_BASE_PATH

# Where partners call us back. OPG.04 leaves the URL to the originating OP; one router under
# the partner-facing prefix keeps every callback on one authenticated path (ADR-0054).
router = APIRouter(prefix=f"{EWBI_BASE_PATH}/callbacks", tags=["FederationCallbacks"])


@router.post(
    "/partner-status",
    operation_id="onPartnerStatusEvent",
    status_code=204,
    responses=EWBI_ERROR_RESPONSES,
)
async def partner_status_event(
    body: PartnerStatusEvent,
    auth: Annotated[PartnerAuthenticator, Depends(get_partner_authenticator)],
    service: Annotated[PartnerStatusCallbackService, Depends(get_partner_status_callback_service)],
    token: Annotated[str, Depends(get_bearer_token)],
) -> None:
    partner = await auth.authenticate(token, scope=NOTIFICATION_SCOPE)
    await service.handle(partner, body)
+6 −2
Original line number Diff line number Diff line
@@ -2,6 +2,9 @@ from federation_manager.domain.errors import AuthenticationFailed, PartnerNotAct
from federation_manager.domain.models import PartnerOP
from federation_manager.domain.ports import JwtValidatorPort, PartnerRepositoryPort

EWBI_SCOPE = "fed-mgmt"
NOTIFICATION_SCOPE = "fed-mgmt-notif"


class PartnerAuthenticator:
    def __init__(
@@ -10,9 +13,10 @@ class PartnerAuthenticator:
        self._partner_repo = partner_repo
        self._jwt_validator = jwt_validator

    async def authenticate(self, token: str) -> PartnerOP:
    async def authenticate(self, token: str, scope: str = EWBI_SCOPE) -> PartnerOP:
        """Resolve a partner from its token. Callbacks pass NOTIFICATION_SCOPE (OPG.04 Sec. 9)."""
        claims = await self._jwt_validator.validate(token)
        if not claims.has_scope("fed-mgmt"):
        if not claims.has_scope(scope):
            raise AuthenticationFailed
        partner = await self._partner_repo.find_by_oauth2_client_id(claims.client_id)
        if partner is None:
+59 −0
Original line number Diff line number Diff line
from federation_manager.application.federation import OUTBOUND, TERMINATED
from federation_manager.contracts.ewbi import PartnerStatusEvent
from federation_manager.core.logging import get_logger
from federation_manager.domain.errors import FederationContextUnknown
from federation_manager.domain.models import FederationContext, PartnerOP
from federation_manager.domain.ports import FederationContextRepositoryPort

logger = get_logger(__name__)

# OPG.04 FederationStatus -> our lowercase column values (RD §K.1 federation_contexts.status).
_STATUS: dict[str, str] = {
    "AVAILABLE": "available",
    "LOCKED": "locked",
    "NOT_AVAILABLE": "not_available",
    "TEMPORARY_FAILURE": "temporary_failure",
    "FAILED": "failed",
}


class PartnerStatusCallbackService:
    """onPartnerStatusEvent: the partner tells us something about a federation we created."""

    def __init__(self, contexts: FederationContextRepositoryPort) -> None:
        self._contexts = contexts

    async def handle(self, partner: PartnerOP, event: PartnerStatusEvent) -> None:
        context = await self._outbound(partner, event.federation_context_id)

        if event.object_type != "FEDERATION" or event.federation_status is None:
            # Zone, network-code and service-API deltas need shared_resource_catalogues
            # (REQ-FED-04), which does not exist yet. Accept the notification rather than
            # make the partner retry a delivery we will never process.
            logger.info(
                "partner_status_callback_ignored",
                partner_op_id=str(partner.id),
                federation_context_id=event.federation_context_id,
                object_type=event.object_type,
                operation_type=event.operation_type,
            )
            return

        status = _STATUS[event.federation_status]
        if status != context.status:
            await self._contexts.set_status(context.id, status)
        logger.info(
            "partner_status_callback_applied",
            partner_op_id=str(partner.id),
            federation_context_id=event.federation_context_id,
            status=status,
        )

    async def _outbound(self, partner: PartnerOP, federation_context_id: str) -> FederationContext:
        # Outbound only: this callback reports on a federation we asked the partner for, and a
        # partner must not reach a context that is not theirs.
        context = await self._contexts.find_outbound(partner.id, federation_context_id)
        if context is None or context.status == TERMINATED:
            raise FederationContextUnknown(partner.id)
        assert context.direction == OUTBOUND
        return context
+36 −0
Original line number Diff line number Diff line
@@ -237,6 +237,42 @@ class ZoneRegistrationResponseData(BaseModel):

FederationStatus = Literal["FAILED", "TEMPORARY_FAILURE", "AVAILABLE", "LOCKED", "NOT_AVAILABLE"]

# onPartnerStatusEvent (OPG.04 CreateFederation callback). Only the fields FM acts on are
# modelled; the rest of the payload (zone, network-code and service-API deltas) is ignored
# until the zone catalogue exists.
PartnerStatusObject = Literal[
    "FEDERATION",
    "ZONES",
    "EDGE_DISCOVERY_SERVICE",
    "LCM_SERVICE",
    "MOBILE_NETWORK_CODES",
    "FIXED_NETWORK_CODES",
    "SERVICE_APIS",
]
PartnerStatusOperation = Literal["STATUS", "UPDATE", "ADD", "REMOVE"]


class PartnerStatusEvent(BaseModel):
    model_config = ConfigDict(extra="ignore", populate_by_name=True)

    federation_context_id: str = Field(
        validation_alias="federationContextId", serialization_alias="federationContextId"
    )
    object_type: PartnerStatusObject = Field(
        validation_alias="objectType", serialization_alias="objectType"
    )
    operation_type: PartnerStatusOperation = Field(
        validation_alias="operationType", serialization_alias="operationType"
    )
    federation_status: FederationStatus | None = Field(
        default=None,
        validation_alias="federationStatus",
        serialization_alias="federationStatus",
    )
    modification_date: datetime = Field(
        validation_alias="modificationDate", serialization_alias="modificationDate"
    )


class FederationHealthInfo(BaseModel):
    model_config = ConfigDict(populate_by_name=True)
Loading