feat(federation): relay the partner registry on /platform/v1/federation/partners

Stacked on !37 (merged) (identity relay); GitLab retargets it to develop once !37 (merged) merges.

The portal's Federation page (portal!6 (merged)) registers a partner before it federates with it, and it calls /platform/v1/federation/partners. OEG had no such route, so every call came back 404 even though FM already serves the registry on /internal/partners. This adds the four relays.

sequenceDiagram
    participant P as Portal
    participant OEG as OEG
    participant FM as FM
    P->>OEG: GET/POST /platform/v1/federation/partners (role operator)
    P->>OEG: GET/PATCH /platform/v1/federation/partners/{partnerOpId}
    OEG->>FM: same method + body on /internal/partners[/{id}]
    FM-->>OEG: 200/201 Partner, or ProblemDetails
    OEG-->>P: relayed unchanged, or 502 if outside FM's contract
Operation FM statuses relayed Meaning
list_partners 200, 500
register_partner 201, 400, 409, 422, 500 400 bad field (invalidParams), 409 mcc_mnc or oauth2_client_id taken, 422 only some outbound credential fields set
get_partner 200, 404, 500 OEG rejects a non-UUID partnerOpId itself, so FM never sees one
update_partner 200, 400, 404, 409, 422, 500
  • OEG doesn't validate the bodies. FM is the only place the registration rules live, so OEG checks only that the body is JSON. FM's success bodies are checked against Partner/PartnerList, the same way the other federation routes are gated.
  • FM only returns ProblemDetails on these routes after federation-manager!29 (merged) merges. Before it, a bad body came back as FastAPI's plain 422 and OEG turned it into a 502.
  • Role operator for all four, in OPERATION_REQUIREMENTS.

Tested: 9 new unit tests in test_federation_manager_endpoints.py (106 pass). End to end on the two-operator docker harness, through OEG-A to FM-A: list, register 201, get, patch, duplicate 409, bad mcc_mnc 400 naming the field, partial credentials 422, unknown 404, non-UUID 400, non-JSON 400. Auth was in log-only mode there, because the harness Keycloak has no realm-oop operator user, so the role check is covered by unit tests only.

Merge request reports

Loading
+112 −3
Changes for src/open_exposure_gateway/api/gsma/federation_manager/v1_4_0/router.py: 112 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -21,9 +21,11 @@ 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``, and ``get_federation_identity``, a read of our
own identity that is not a GSMA operation. ``update_federation`` (``UpdateFederation``)
is out of scope.
``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.
"""

import json
@@ -53,6 +55,8 @@ from open_exposure_gateway.api.gsma.federation_manager.v1_4_0.schemas import (
    FederationIdentity,
    FederationRequestData,
    FederationResponseData,
    Partner,
    PartnerList,
    PartnerOpId,
    ProblemDetails,
    ZoneIdentifier,
@@ -81,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"},
@@ -100,6 +105,15 @@ _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"


@@ -244,6 +258,101 @@ async def get_federation_identity(
    )


@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"],
+29 −2
Changes for src/open_exposure_gateway/api/gsma/federation_manager/v1_4_0/schemas.py: 29 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"

@@ -226,6 +226,33 @@ class FederationIdentity(BaseModel):
    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
+4 −0
Changes for src/open_exposure_gateway/core/authorization.py: 4 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -104,6 +104,10 @@ OPERATION_REQUIREMENTS: dict[str, OperationRequirements] = {
    ),
    # 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")
    ),
+171 −0
Changes for tests/unit/test_federation_manager_endpoints.py: 171 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -56,6 +56,17 @@ _IDENTITY = (
    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"}'

@@ -167,6 +178,10 @@ _ENDPOINTS = [

_OPERATION_IDS = {
    "get_federation_identity",
    "list_partners",
    "register_partner",
    "get_partner",
    "update_partner",
    "create_federation",
    "get_federation_context_id",
    "get_federation_details",
@@ -680,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