feat(federation): relay our federation identity on GET /platform/v1/federation/identity

Relays FM's new GET /internal/identity (federation-manager!28 (merged)) so the portal can fill partnerStatusLink in CreateFederation instead of asking the operator (portal!6 (merged)).

  • Role operator, like the other federation routes (ADR-0051).
  • FederationIdentity schema, marked as a platform extension (not GSMA), snake_case like FM's /internal API.
  • FM may only answer 200 or 500 here (no input); anything else is outside its contract → OEG 502 (Sec. K.6a).

Merge request reports

Loading
+138 −1
Changes for src/open_exposure_gateway/api/gsma/federation_manager/v1_4_0/router.py: 138 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -21,7 +21,10 @@ response that passes is still relayed byte-for-byte.

This module exposes the ``FederationManagement`` tag plus ``zone_subscribe``,
``get_zone_data``, ``get_zone_details`` and ``zone_unsubscribe`` from
``AvailabilityZoneInfoSynchronization``. ``update_federation``
``AvailabilityZoneInfoSynchronization``, plus two things that are not GSMA operations:
``get_federation_identity``, a read of our own identity, and the partner registry
(``list_partners``, ``register_partner``, ``get_partner``, ``update_partner``), which the
portal fills before it can federate with anyone. ``update_federation``
(``UpdateFederation``) is out of scope.
"""

@@ -49,8 +52,11 @@ from open_exposure_gateway.api.gsma.federation_manager.v1_4_0.schemas import (
    FederationContextId,
    FederationContextIdResponse,
    FederationDetails,
    FederationIdentity,
    FederationRequestData,
    FederationResponseData,
    Partner,
    PartnerList,
    PartnerOpId,
    ProblemDetails,
    ZoneIdentifier,
@@ -79,6 +85,7 @@ _ERROR_RESPONSES: dict[int | str, dict[str, Any]] = {
    409: {"model": ProblemDetails, "description": "Conflict"},
    422: {"model": ProblemDetails, "description": "Unprocessable entity"},
    500: {"model": ProblemDetails, "description": "Internal server error"},
    501: {"model": ProblemDetails, "description": "Not implemented"},
    502: {"model": ProblemDetails, "description": "Bad gateway"},
    503: {"model": ProblemDetails, "description": "Service unavailable"},
    520: {"model": ProblemDetails, "description": "Unknown error"},
@@ -95,6 +102,18 @@ def _responses(*codes: int) -> dict[int | str, dict[str, Any]]:
# OEG's own and never FM's.
_FM_STATUSES = frozenset({200, 400, 401, 404, 409, 422, 500, 503, 520})

# GET /identity has no input and reads FM's config, so FM can only fail unexpectedly.
_FM_IDENTITY_STATUSES = frozenset({200, 500})

# The partner registry is FM's own API. FM answers a bad body with 400 and a registration
# with incomplete outbound credentials with 422; 409 means the mcc_mnc or oauth2_client_id
# is already taken, and 501 that FM has no key to store a typed our_client_secret.
# OEG checks partnerOpId itself, so FM never sees a malformed one.
_FM_PARTNER_LIST_STATUSES = frozenset({200, 500})
_FM_PARTNER_REGISTER_STATUSES = frozenset({201, 400, 409, 422, 500, 501})
_FM_PARTNER_GET_STATUSES = frozenset({200, 404, 500})
_FM_PARTNER_UPDATE_STATUSES = frozenset({200, 400, 404, 409, 422, 500, 501})

_PROBLEM_JSON = "application/problem+json"


@@ -216,6 +235,124 @@ async def _relay(
    )


@router.get(
    "/identity",
    tags=["Federation Manager"],
    summary="Retrieve our own federation identity, as sent to every partner in CreateFederation",
    operation_id="get_federation_identity",
    response_model=FederationIdentity,
    responses=_responses(401, 500, 502, 503),
)
async def get_federation_identity(
    request: Request,
    fm_client: FmClientDep,
    x_correlator: XCorrelatorHeader = None,
) -> Response:
    return await _relay(
        fm_client,
        "GET",
        "/identity",
        request,
        fm_statuses=_FM_IDENTITY_STATUSES,
        response_schema=FederationIdentity,
    )


@router.get(
    "/partners",
    tags=["Federation Manager"],
    summary="List the partner operator platforms registered with our FM",
    operation_id="list_partners",
    response_model=list[Partner],
    responses=_responses(401, 500, 502, 503),
)
async def list_partners(
    request: Request,
    fm_client: FmClientDep,
    x_correlator: XCorrelatorHeader = None,
) -> Response:
    return await _relay(
        fm_client,
        "GET",
        "/partners",
        request,
        fm_statuses=_FM_PARTNER_LIST_STATUSES,
        response_schema=PartnerList,
    )


@router.post(
    "/partners",
    status_code=201,
    tags=["Federation Manager"],
    summary="Register a partner operator platform before federating with it",
    operation_id="register_partner",
    response_model=Partner,
    responses=_responses(400, 401, 409, 422, 500, 501, 502, 503),
)
async def register_partner(
    request: Request,
    fm_client: FmClientDep,
    x_correlator: XCorrelatorHeader = None,
) -> Response:
    return await _relay(
        fm_client,
        "POST",
        "/partners",
        request,
        fm_statuses=_FM_PARTNER_REGISTER_STATUSES,
        response_schema=Partner,
    )


@router.get(
    "/partners/{partnerOpId}",
    tags=["Federation Manager"],
    summary="Retrieve one registered partner operator platform",
    operation_id="get_partner",
    response_model=Partner,
    responses=_responses(400, 401, 404, 500, 502, 503),
)
async def get_partner(
    partnerOpId: PartnerOpId,
    request: Request,
    fm_client: FmClientDep,
    x_correlator: XCorrelatorHeader = None,
) -> Response:
    return await _relay(
        fm_client,
        "GET",
        f"/partners/{partnerOpId}",
        request,
        fm_statuses=_FM_PARTNER_GET_STATUSES,
        response_schema=Partner,
    )


@router.patch(
    "/partners/{partnerOpId}",
    tags=["Federation Manager"],
    summary="Update a registered partner; fields left out are unchanged",
    operation_id="update_partner",
    response_model=Partner,
    responses=_responses(400, 401, 404, 409, 422, 500, 501, 502, 503),
)
async def update_partner(
    partnerOpId: PartnerOpId,
    request: Request,
    fm_client: FmClientDep,
    x_correlator: XCorrelatorHeader = None,
) -> Response:
    return await _relay(
        fm_client,
        "PATCH",
        f"/partners/{partnerOpId}",
        request,
        fm_statuses=_FM_PARTNER_UPDATE_STATUSES,
        response_schema=Partner,
    )


@router.post(
    PARTNER_PREFIX,
    tags=["Federation Manager"],
+53 −2
Changes for src/open_exposure_gateway/api/gsma/federation_manager/v1_4_0/schemas.py: 53 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -16,10 +16,10 @@ from __future__ import annotations

from datetime import datetime
from enum import StrEnum
from typing import Annotated, Optional
from typing import Annotated, Literal, Optional
from uuid import UUID

from pydantic import BaseModel, Field, StringConstraints, model_validator
from pydantic import BaseModel, Field, RootModel, StringConstraints, model_validator

SOURCE_API_VERSION = "1.4.0"

@@ -202,6 +202,57 @@ class FederationContextIdResponse(BaseModel):
    FederationContextId: FederationContextId


# --- Our own federation identity (platform extension, not GSMA) ----------------


class IdentityNetworkCodes(BaseModel):
    mcc: Mcc
    mncs: list[Mnc]


class FederationIdentity(BaseModel):
    """``200`` body of ``GET /platform/v1/federation/identity``: what our FM sends every
    partner in ``CreateFederation``, read from FM's config. Not a GSMA schema, so field
    names follow FM's ``/internal`` API (snake_case). The portal fills
    ``partnerStatusLink`` from ``partner_status_link`` instead of asking the operator,
    since FM rejects any other value."""

    federation_id: FederationIdentifier
    country_code: CountryCode
    mobile_network_codes: IdentityNetworkCodes
    platform_caps: list[str]
    partner_status_link: Uri
    instance_status_link: Optional[Uri] = None
    avail_zone_notif_link: Optional[Uri] = None


# --- Partner registry (platform extension, not GSMA) ---------------------------

PartnerStatus = Literal["pending", "active", "suspended", "decommissioned"]


class Partner(BaseModel):
    """One partner OP as FM's registry holds it, in FM's ``/internal`` field names. OEG
    checks only that request bodies are JSON: FM validates registrations and updates and
    answers a bad one with a ``400`` ProblemDetails naming the field."""

    id: UUID
    name: str
    mcc_mnc: str
    oauth2_client_id: str
    base_url: Optional[Uri] = None
    our_client_id: Optional[str] = None
    our_client_secret_ref: Optional[str] = None
    token_endpoint: Optional[Uri] = None
    status: PartnerStatus
    # FM stores a typed our_client_secret encrypted and only ever reports that one is set
    our_client_secret_set: bool = False


class PartnerList(RootModel[list[Partner]]):
    """``200`` body of ``GET /platform/v1/federation/partners``."""


# --- AvailabilityZoneInfoSynchronization --------------------------------------
#
# Schemas for ``ZoneSubscribe``: the Originating OP subscribes to a set of the
+5 −0
Changes for src/open_exposure_gateway/core/authorization.py: 5 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -103,6 +103,11 @@ OPERATION_REQUIREMENTS: dict[str, OperationRequirements] = {
        "app-provider", scope="traffic-influence:traffic-influence-devices:write"
    ),
    # Federation Manager (platform namespace, ADR-0051): operator-only, no CAMARA scope.
    "GET /platform/v1/federation/identity": OperationRequirements("operator"),
    "GET /platform/v1/federation/partners": OperationRequirements("operator"),
    "POST /platform/v1/federation/partners": OperationRequirements("operator"),
    "GET /platform/v1/federation/partners/{partnerOpId}": OperationRequirements("operator"),
    "PATCH /platform/v1/federation/partners/{partnerOpId}": OperationRequirements("operator"),
    "POST /platform/v1/federation/partners/{partnerOpId}/federations": (
        OperationRequirements("operator")
    ),
+191 −0
Changes for tests/unit/test_federation_manager_endpoints.py: 191 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -50,6 +50,23 @@ _ZONE_REGISTERED = (
    b'"numCPU": 2, "memorySize": 4, "storageSize": 20}]}'
)
_ZONE_REGISTRATION_RESPONSE = b'{"acceptedZoneResourceInfo": [' + _ZONE_REGISTERED + b"]}"
_IDENTITY = (
    b'{"federation_id": "oop-local", "country_code": "ES", '
    b'"mobile_network_codes": {"mcc": "214", "mncs": ["07"]}, "platform_caps": ["serviceAPIs"], '
    b'"partner_status_link": "https://fm.example.com/callbacks/partner-status", '
    b'"instance_status_link": null, "avail_zone_notif_link": null}'
)
_PARTNER_BODY = (
    b'{"id": "5f0c2a8e-3b1d-4c6e-9a7f-1d2e3f4a5b6c", "name": "Partner A", '
    b'"mcc_mnc": "208-01", "oauth2_client_id": "partner-a", '
    b'"base_url": "https://partner-a.example", "our_client_id": null, '
    b'"our_client_secret_ref": null, "token_endpoint": null, "status": "pending", '
    b'"our_client_secret_set": false}'
)
_REGISTRATION = (
    b'{"name": "Partner A", "mcc_mnc": "208-01", "oauth2_client_id": "partner-a", '
    b'"base_url": "https://partner-a.example"}'
)

_PROBLEM = b'{"title": "Conflict", "detail": "federation already exists", "cause": "CONFLICT"}'

@@ -106,6 +123,7 @@ def client() -> TestClient:

# (http method, OEG url, FM method, FM path, request body, FM's 200 body)
_ENDPOINTS = [
    ("get", f"{BASE_PATH}/identity", "GET", "/identity", None, _IDENTITY),
    (
        "post",
        f"{BASE_PATH}{_FEDERATIONS}",
@@ -159,6 +177,11 @@ _ENDPOINTS = [
]

_OPERATION_IDS = {
    "get_federation_identity",
    "list_partners",
    "register_partner",
    "get_partner",
    "update_partner",
    "create_federation",
    "get_federation_context_id",
    "get_federation_details",
@@ -605,6 +628,18 @@ def test_invalid_x_correlator_returns_problem_details_400(
    assert fake_fm_client.calls == []


@pytest.mark.parametrize("status", [400, 404, 409, 422, 503])
def test_identity_relays_only_the_statuses_fm_can_return(
    client: TestClient, fake_fm_client: _FakeFmClient, status: int
) -> None:
    """GET /identity has no input, so FM's client errors are outside its contract."""
    fake_fm_client.response = httpx.Response(
        status, content=_PROBLEM, headers={"content-type": "application/problem+json"}
    )

    _assert_bad_response_502(client.get(f"{BASE_PATH}/identity"))


def test_unknown_federation_path_returns_problem_details_404(client: TestClient) -> None:
    response = client.get(f"{BASE_PATH}/no-such-thing")

@@ -660,3 +695,159 @@ def test_lookalike_prefix_still_returns_error_info(client: TestClient) -> None:
    assert response.status_code == 404
    assert response.headers["content-type"] == "application/json"
    assert response.json()["code"] == "NOT_FOUND"


# (http method, OEG url, FM path, request body, FM's success status and body)
_PARTNER_ENDPOINTS = [
    ("get", "/partners", None, 200, b"[" + _PARTNER_BODY + b"]"),
    ("post", "/partners", _REGISTRATION, 201, _PARTNER_BODY),
    ("get", f"/partners/{_PARTNER}", None, 200, _PARTNER_BODY),
    ("patch", f"/partners/{_PARTNER}", b'{"status": "active"}', 200, _PARTNER_BODY),
]


@pytest.mark.parametrize(("http_method", "path", "body", "status", "fm_body"), _PARTNER_ENDPOINTS)
def test_partner_registry_relays_to_fm_unaltered(
    client: TestClient,
    fake_fm_client: _FakeFmClient,
    http_method: str,
    path: str,
    body: bytes | None,
    status: int,
    fm_body: bytes,
) -> None:
    fake_fm_client.response = httpx.Response(
        status, content=fm_body, headers={"content-type": "application/json"}
    )
    headers = {"content-type": "application/json"} if body else {}

    response = client.request(http_method, f"{BASE_PATH}{path}", content=body, headers=headers)

    assert response.status_code == status
    assert response.content == fm_body
    call = fake_fm_client.calls[0]
    assert (call["method"], call["path"], call["content"]) == (http_method.upper(), path, body)


def test_partner_registration_is_not_validated_by_oeg(
    client: TestClient, fake_fm_client: _FakeFmClient
) -> None:
    """FM owns the registration rules; OEG forwards any JSON and relays FM's verdict."""
    problem = (
        b'{"type": "urn:oop:ewbi:error:invalid-request", "title": "Invalid Request", '
        b'"status": 400, "invalidParams": [{"param": "mcc_mnc", "reason": "bad"}]}'
    )
    fake_fm_client.response = httpx.Response(
        400, content=problem, headers={"content-type": "application/problem+json"}
    )

    response = client.post(
        f"{BASE_PATH}/partners",
        content=b'{"mcc_mnc": "x"}',
        headers={"content-type": "application/json"},
    )

    assert response.status_code == 400
    assert response.content == problem
    assert fake_fm_client.calls[0]["content"] == b'{"mcc_mnc": "x"}'


@pytest.mark.parametrize(
    ("http_method", "path"), [("post", "/partners"), ("patch", f"/partners/{_PARTNER}")]
)
def test_partner_secret_storage_unavailable_is_relayed(
    client: TestClient, fake_fm_client: _FakeFmClient, http_method: str, path: str
) -> None:
    """FM has no key to store a typed our_client_secret; the operator must see why."""
    problem = (
        b'{"type": "urn:oop:ewbi:error:partner-secret-storage-unavailable", '
        b'"title": "Partner Secret Storage Not Configured", "status": 501}'
    )
    fake_fm_client.response = httpx.Response(
        501, content=problem, headers={"content-type": "application/problem+json"}
    )

    response = client.request(
        http_method,
        f"{BASE_PATH}{path}",
        content=b'{"our_client_secret": "bbb222"}',
        headers={"content-type": "application/json"},
    )

    assert response.status_code == 501
    assert response.content == problem


@pytest.mark.parametrize(
    ("http_method", "path", "status"),
    [
        ("get", "/partners", 404),
        ("post", "/partners", 200),
        ("get", f"/partners/{_PARTNER}", 409),
        ("patch", f"/partners/{_PARTNER}", 201),
    ],
)
def test_partner_registry_rejects_statuses_fm_does_not_declare(
    client: TestClient, fake_fm_client: _FakeFmClient, http_method: str, path: str, status: int
) -> None:
    fake_fm_client.response = httpx.Response(
        status, content=_PARTNER_BODY, headers={"content-type": "application/json"}
    )
    body = b"{}" if http_method in ("post", "patch") else None

    _assert_bad_response_502(
        client.request(
            http_method,
            f"{BASE_PATH}{path}",
            content=body,
            headers={"content-type": "application/json"},
        )
    )


@pytest.mark.parametrize(("http_method", "path", "body", "status", "fm_body"), _PARTNER_ENDPOINTS)
@pytest.mark.parametrize("bad_body", [b"", b"{}", b"[{}]", b"{not valid json"])
def test_partner_success_body_not_matching_the_response_schema_returns_502(
    client: TestClient,
    fake_fm_client: _FakeFmClient,
    http_method: str,
    path: str,
    body: bytes | None,
    status: int,
    fm_body: bytes,
    bad_body: bytes,
) -> None:
    fake_fm_client.response = httpx.Response(
        status, content=bad_body, headers={"content-type": "application/json"}
    )
    headers = {"content-type": "application/json"} if body else {}

    _assert_bad_response_502(
        client.request(http_method, f"{BASE_PATH}{path}", content=body, headers=headers)
    )


def test_partner_error_without_problem_details_returns_502(
    client: TestClient, fake_fm_client: _FakeFmClient
) -> None:
    """FastAPI's own validation error is plain JSON, not ProblemDetails."""
    fake_fm_client.response = httpx.Response(
        422,
        content=b'{"detail": [{"loc": ["body"], "msg": "x"}]}',
        headers={"content-type": "application/json"},
    )

    _assert_bad_response_502(
        client.post(
            f"{BASE_PATH}/partners", content=b"{}", headers={"content-type": "application/json"}
        )
    )


def test_non_uuid_partner_op_id_on_the_registry_never_reaches_fm(
    client: TestClient, fake_fm_client: _FakeFmClient
) -> None:
    response = client.get(f"{BASE_PATH}/partners/not-a-uuid")

    assert response.status_code == 400
    assert fake_fm_client.calls == []
Loading