Commit 2cc872db authored by Sergio Gimenez's avatar Sergio Gimenez
Browse files

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

The portal has to send partnerStatusLink in CreateFederation, and FM
rejects anything but its own configured value, so the operator cannot
type it. Relay FM's new GET /internal/identity (operator role) so the
portal can read it instead. FM can only answer 200 or 500 here; any
other status is outside its contract and becomes OEG's 502.
parent 469e1962
Loading
Loading
Loading
Loading
Loading
+30 −2
Changes for src/open_exposure_gateway/api/gsma/federation_manager/v1_4_0/router.py: 30 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -21,8 +21,9 @@ 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``
(``UpdateFederation``) is out of scope.
``AvailabilityZoneInfoSynchronization``, and ``get_federation_identity``, a read of our
own identity that is not a GSMA operation. ``update_federation`` (``UpdateFederation``)
is out of scope.
"""

import json
@@ -49,6 +50,7 @@ from open_exposure_gateway.api.gsma.federation_manager.v1_4_0.schemas import (
    FederationContextId,
    FederationContextIdResponse,
    FederationDetails,
    FederationIdentity,
    FederationRequestData,
    FederationResponseData,
    PartnerOpId,
@@ -95,6 +97,9 @@ 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})

_PROBLEM_JSON = "application/problem+json"


@@ -216,6 +221,29 @@ 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.post(
    PARTNER_PREFIX,
    tags=["Federation Manager"],
+24 −0
Changes for src/open_exposure_gateway/api/gsma/federation_manager/v1_4_0/schemas.py: 24 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -202,6 +202,30 @@ 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


# --- AvailabilityZoneInfoSynchronization --------------------------------------
#
# Schemas for ``ZoneSubscribe``: the Originating OP subscribes to a set of the
+1 −0
Changes for src/open_exposure_gateway/core/authorization.py: 1 added line, 0 removed lines.
Original line number Diff line number Diff line
@@ -103,6 +103,7 @@ 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"),
    "POST /platform/v1/federation/partners/{partnerOpId}/federations": (
        OperationRequirements("operator")
    ),
+20 −0
Changes for tests/unit/test_federation_manager_endpoints.py: 20 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -50,6 +50,12 @@ _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}'
)

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

@@ -106,6 +112,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 +166,7 @@ _ENDPOINTS = [
]

_OPERATION_IDS = {
    "get_federation_identity",
    "create_federation",
    "get_federation_context_id",
    "get_federation_details",
@@ -605,6 +613,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")