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

feat(fm): create and update federation agreements on /internal/agreements

FM checked agreements on every partner request but nothing could write
one, so a partner never saw a zone offered and InstallApp always failed
the agreement check. POST /internal/agreements stores one with the
federation_agreements columns as fields; PATCH /internal/agreements/{id}
changes status, usage limits and validity, the parts the spec lets an
update touch. Other terms take a new agreement.

Zone terms are checked against the GSMA ZoneTerms model when written,
and only for zones the agreement permits, since zone offers drop a zone
whose terms don't validate. GET /internal/partners/{id} now embeds the
partner's agreements, oldest first; the list endpoint doesn't.

Co-Authored-By: default avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 269b6e88
Loading
Loading
Loading
Loading
Loading
+47 −3
Changes for src/federation_manager/adapters/database/agreement_repo.py: 47 added lines, 3 removed lines.
Original line number Diff line number Diff line
from typing import Any
from uuid import UUID

from sqlalchemy import and_, func, or_, select
from sqlalchemy import Row, and_, func, insert, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession

from federation_manager.adapters.database.tables import federation_agreements as agreements
@@ -26,8 +27,37 @@ class PostgresAgreementRepo:
            .limit(1)
        )
        row = (await self._session.execute(stmt)).one_or_none()
        if row is None:
            return None
        return None if row is None else _to_agreement(row)

    async def find_by_id(self, agreement_id: UUID) -> Agreement | None:
        stmt = select(agreements).where(agreements.c.id == agreement_id)
        row = (await self._session.execute(stmt)).one_or_none()
        return None if row is None else _to_agreement(row)

    async def list_for_partner(self, partner_id: UUID) -> list[Agreement]:
        stmt = (
            select(agreements)
            .where(agreements.c.partner_op_id == partner_id)
            .order_by(agreements.c.created_at)
        )
        return [_to_agreement(row) for row in (await self._session.execute(stmt)).all()]

    async def add(self, agreement: Agreement) -> None:
        await self._session.execute(
            insert(agreements).values(
                id=agreement.id, partner_op_id=agreement.partner_op_id, **_values(agreement)
            )
        )
        await self._session.commit()

    async def update(self, agreement: Agreement) -> None:
        await self._session.execute(
            update(agreements).where(agreements.c.id == agreement.id).values(**_values(agreement))
        )
        await self._session.commit()


def _to_agreement(row: Row[Any]) -> Agreement:
    return Agreement(
        id=row.id,
        partner_op_id=row.partner_op_id,
@@ -50,4 +80,18 @@ class PostgresAgreementRepo:
        valid_until=row.valid_until,
        status=row.status,
        zone_resources={UUID(str(k)): v for k, v in (row.zone_resources or {}).items()},
        usage_limits=dict(row.usage_limits or {}),
    )


def _values(agreement: Agreement) -> dict[str, Any]:
    return {
        "permitted_api_types": sorted(agreement.permitted_api_types),
        "permitted_zone_ids": sorted(str(z) for z in agreement.permitted_zone_ids),
        "service_spec_mappings": agreement.service_spec_mappings(),
        "usage_limits": agreement.usage_limits,
        "zone_resources": {str(k): v for k, v in agreement.zone_resources.items()},
        "valid_from": agreement.valid_from,
        "valid_until": agreement.valid_until,
        "status": agreement.status,
    }
+20 −0
Changes for src/federation_manager/api/errors.py: 20 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -10,6 +10,8 @@ from pydantic.json_schema import models_json_schema
from federation_manager.contracts.ewbi import InvalidParam, ProblemDetails
from federation_manager.domain.errors import (
    AgreementExpired,
    AgreementNotFound,
    AgreementValidityInvalid,
    AgreementViolation,
    AppInstanceUnknown,
    AuthenticationFailed,
@@ -392,6 +394,24 @@ def register_exception_handlers(app: FastAPI) -> None:
            request.url.path,
        )

    @app.exception_handler(AgreementNotFound)
    async def _agreement_not_found(request: Request, exc: AgreementNotFound) -> JSONResponse:
        return problem(
            404,
            "agreement-not-found",
            "Agreement Not Found",
            "No federation agreement exists with this identifier.",
            request.url.path,
        )

    @app.exception_handler(AgreementValidityInvalid)
    async def _agreement_validity_invalid(
        request: Request, exc: AgreementValidityInvalid
    ) -> JSONResponse:
        return problem(
            422, "agreement-validity-invalid", "Invalid Validity Period", str(exc), request.url.path
        )

    @app.exception_handler(PartnerCredentialsIncomplete)
    async def _credentials_incomplete(
        request: Request, exc: PartnerCredentialsIncomplete
+158 −0
Changes for src/federation_manager/api/internal/agreements.py: 158 added lines, 0 removed lines.
Original line number Diff line number Diff line
from datetime import datetime
from typing import Annotated, Any, Literal, Self
from uuid import UUID

from fastapi import APIRouter, Depends
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, model_validator

from federation_manager.application.agreements import AgreementService
from federation_manager.contracts.ewbi import ZoneTerms
from federation_manager.dependencies import get_agreement_service
from federation_manager.domain.models import Agreement, AppMapping

router = APIRouter(prefix="/internal/agreements", tags=["internal-agreements"])

Service = Annotated[AgreementService, Depends(get_agreement_service)]

# the values FM checks a partner request against; anything else would never match
ApiType = Literal[
    "install-app",
    "remove-app",
    "device-location",
    "qod-session",
    "qod-session-remove",
    "device-location-retrieve",
    "device-status-retrieve",
]
CreateStatus = Literal["draft", "active"]
Status = Literal["draft", "active", "suspended", "expired"]


class AppSpecMapping(BaseModel):
    """A partner's (appId, appVersion, flavourId) mapped to our service specification."""

    model_config = ConfigDict(extra="forbid")

    appId: str = Field(min_length=1)
    appVersion: str = Field(min_length=1)
    flavourId: str = Field(min_length=1)
    service_specification_id: UUID


class ServiceSpecMappings(BaseModel):
    model_config = ConfigDict(extra="forbid")

    apps: list[AppSpecMapping] = []
    api_families: dict[str, UUID] = {}


class AgreementCreate(BaseModel):
    model_config = ConfigDict(extra="forbid")

    partner_op_id: UUID
    permitted_api_types: list[ApiType]
    permitted_zone_ids: list[UUID] = Field(
        default=[], description="Our SRM zone ids this partner may see and use"
    )
    zone_resources: dict[UUID, ZoneTerms] = Field(
        default={},
        description="Per permitted zone, the GSMA terms: reserved resources, quota, flavours",
    )
    service_spec_mappings: ServiceSpecMappings = ServiceSpecMappings()
    usage_limits: dict[str, Any] = {}
    valid_from: AwareDatetime
    valid_until: AwareDatetime | None = None
    status: CreateStatus = "draft"

    @model_validator(mode="after")
    def _terms_only_for_permitted_zones(self) -> Self:
        stray = set(self.zone_resources) - set(self.permitted_zone_ids)
        if stray:
            raise ValueError("zone_resources lists zones missing from permitted_zone_ids")
        return self

    def fields(self) -> dict[str, Any]:
        return {
            "partner_op_id": self.partner_op_id,
            "permitted_api_types": set(self.permitted_api_types),
            "permitted_zone_ids": set(self.permitted_zone_ids),
            "zone_resources": {
                zone: terms.model_dump(mode="json", by_alias=True, exclude_none=True)
                for zone, terms in self.zone_resources.items()
            },
            "app_mappings": tuple(
                AppMapping(
                    app_id=m.appId,
                    app_version=m.appVersion,
                    flavour_id=m.flavourId,
                    service_specification_id=m.service_specification_id,
                )
                for m in self.service_spec_mappings.apps
            ),
            "api_family_mappings": dict(self.service_spec_mappings.api_families),
            "usage_limits": self.usage_limits,
            "valid_from": self.valid_from,
            "valid_until": self.valid_until,
            "status": self.status,
        }


class AgreementUpdate(BaseModel):
    """Partial update of status, limits and validity; other terms need a new agreement."""

    model_config = ConfigDict(extra="forbid")

    status: Status | None = None
    usage_limits: dict[str, Any] | None = None
    valid_from: AwareDatetime | None = None
    valid_until: AwareDatetime | None = None

    @model_validator(mode="after")
    def _required_not_cleared(self) -> Self:
        for field in ("status", "usage_limits", "valid_from"):
            if field in self.model_fields_set and getattr(self, field) is None:
                raise ValueError(f"{field} cannot be null")
        return self

    def changes(self) -> dict[str, Any]:
        return self.model_dump(exclude_unset=True)


class AgreementView(BaseModel):
    id: UUID
    partner_op_id: UUID
    permitted_api_types: list[str]
    permitted_zone_ids: list[UUID]
    zone_resources: dict[UUID, dict[str, Any]]
    service_spec_mappings: dict[str, Any]
    usage_limits: dict[str, Any]
    valid_from: datetime
    valid_until: datetime | None
    status: str

    @classmethod
    def of(cls, agreement: Agreement) -> "AgreementView":
        return cls(
            id=agreement.id,
            partner_op_id=agreement.partner_op_id,
            permitted_api_types=sorted(agreement.permitted_api_types),
            permitted_zone_ids=sorted(agreement.permitted_zone_ids, key=str),
            zone_resources=dict(agreement.zone_resources),
            service_spec_mappings=agreement.service_spec_mappings(),
            usage_limits=dict(agreement.usage_limits),
            valid_from=agreement.valid_from,
            valid_until=agreement.valid_until,
            status=agreement.status,
        )


@router.post("", status_code=201, response_model=AgreementView)
async def create_agreement(body: AgreementCreate, service: Service) -> AgreementView:
    return AgreementView.of(await service.create(body.fields()))


@router.patch("/{agreement_id}", response_model=AgreementView)
async def update_agreement(
    agreement_id: UUID, body: AgreementUpdate, service: Service
) -> AgreementView:
    return AgreementView.of(await service.update(agreement_id, body.changes()))
+19 −4
Changes for src/federation_manager/api/internal/partners.py: 19 added lines, 4 removed lines.
Original line number Diff line number Diff line
@@ -5,13 +5,16 @@ from uuid import UUID
from fastapi import APIRouter, Depends
from pydantic import AfterValidator, BaseModel, ConfigDict, Field, SecretStr, model_validator

from federation_manager.api.internal.agreements import AgreementView
from federation_manager.application.agreements import AgreementService
from federation_manager.application.partners import PartnerRegistration, PartnerRegistryService
from federation_manager.dependencies import get_partner_registry_service
from federation_manager.dependencies import get_agreement_service, get_partner_registry_service
from federation_manager.domain.models import PartnerOP

router = APIRouter(prefix="/internal/partners", tags=["internal-partners"])

Service = Annotated[PartnerRegistryService, Depends(get_partner_registry_service)]
Agreements = Annotated[AgreementService, Depends(get_agreement_service)]


def _http_url(value: str) -> str:
@@ -129,6 +132,12 @@ class PartnerView(BaseModel):
        return cls.model_validate(partner, from_attributes=True)


class PartnerDetailView(PartnerView):
    agreements: list[AgreementView] = Field(
        description="Every agreement with this partner, oldest first, whatever its status"
    )


@router.post("", status_code=201, response_model=PartnerView)
async def register_partner(body: PartnerCreate, service: Service) -> PartnerView:
    return PartnerView.of(await service.register(body.registration()))
@@ -139,9 +148,15 @@ async def list_partners(service: Service) -> list[PartnerView]:
    return [PartnerView.of(partner) for partner in await service.list()]


@router.get("/{partner_op_id}", response_model=PartnerView)
async def get_partner(partner_op_id: UUID, service: Service) -> PartnerView:
    return PartnerView.of(await service.get(partner_op_id))
@router.get("/{partner_op_id}", response_model=PartnerDetailView)
async def get_partner(
    partner_op_id: UUID, service: Service, agreements: Agreements
) -> PartnerDetailView:
    partner = PartnerView.of(await service.get(partner_op_id))
    return PartnerDetailView(
        **partner.model_dump(),
        agreements=[AgreementView.of(a) for a in await agreements.list_for_partner(partner_op_id)],
    )


@router.patch("/{partner_op_id}", response_model=PartnerView)
+58 −0
Changes for src/federation_manager/application/agreements.py: 58 added lines, 0 removed lines.
Original line number Diff line number Diff line
from collections.abc import Callable
from dataclasses import replace
from typing import Any
from uuid import UUID, uuid4

from federation_manager.domain.errors import (
    AgreementNotFound,
    AgreementValidityInvalid,
    PartnerNotRegistered,
)
from federation_manager.domain.models import Agreement
from federation_manager.domain.ports import AgreementRepositoryPort, PartnerRepositoryPort


class AgreementService:
    """Records the terms two operators agreed on; FM enforces them on every partner request."""

    def __init__(
        self,
        agreements: AgreementRepositoryPort,
        partners: PartnerRepositoryPort,
        *,
        id_factory: Callable[[], UUID] = uuid4,
    ) -> None:
        self._agreements = agreements
        self._partners = partners
        self._new_id = id_factory

    async def create(self, fields: dict[str, Any]) -> Agreement:
        await self._require_partner(fields["partner_op_id"])
        agreement = Agreement(id=self._new_id(), **fields)
        _require_valid_period(agreement)
        await self._agreements.add(agreement)
        return agreement

    async def get(self, agreement_id: UUID) -> Agreement:
        agreement = await self._agreements.find_by_id(agreement_id)
        if agreement is None:
            raise AgreementNotFound(agreement_id)
        return agreement

    async def update(self, agreement_id: UUID, changes: dict[str, Any]) -> Agreement:
        updated = replace(await self.get(agreement_id), **changes)
        _require_valid_period(updated)
        await self._agreements.update(updated)
        return updated

    async def list_for_partner(self, partner_id: UUID) -> list[Agreement]:
        return await self._agreements.list_for_partner(partner_id)

    async def _require_partner(self, partner_id: UUID) -> None:
        if await self._partners.find_by_id(partner_id) is None:
            raise PartnerNotRegistered(partner_id)


def _require_valid_period(agreement: Agreement) -> None:
    if agreement.valid_until is not None and agreement.valid_until <= agreement.valid_from:
        raise AgreementValidityInvalid
Loading