Commit 17a4e8f8 authored by Sergio Gimenez's avatar Sergio Gimenez
Browse files

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

The portal registers a partner before it can federate with it. Relay FM's
list, register, get and update routes (operator role), each gated on the
statuses FM returns for it. FM validates the bodies; OEG only checks they
are JSON.
parent 2cc872db
Loading
Loading
Loading
Loading
Loading
+110 −3
Changes for src/open_exposure_gateway/api/gsma/federation_manager/v1_4_0/router.py: 110 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,
@@ -100,6 +104,14 @@ _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. 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})
_FM_PARTNER_GET_STATUSES = frozenset({200, 404, 500})
_FM_PARTNER_UPDATE_STATUSES = frozenset({200, 400, 404, 409, 422, 500})

_PROBLEM_JSON = "application/problem+json"


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


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")
    ),
+122 −0
Changes for tests/unit/test_federation_manager_endpoints.py: 122 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -56,6 +56,16 @@ _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"}'
)
_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 +177,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 +694,111 @@ 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", "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"},
        )
    )


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 == []