Commit 6d7fb887 authored by Sergio Gimenez's avatar Sergio Gimenez
Browse files

Merge branch 'feat/partner-registration' into 'develop'

feat(fm): partner registration admin API (#20)

See merge request !20
parents 28767629 820e74c6
Loading
Loading
Loading
Loading
Loading
+50 −1
Original line number Diff line number Diff line
from typing import Any
from uuid import UUID

from sqlalchemy import Select, select
from sqlalchemy import Select, insert, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession

from federation_manager.adapters.database.tables import partner_ops
from federation_manager.domain.errors import PartnerRegistrationConflict
from federation_manager.domain.models import PartnerOP

# columns carrying a UNIQUE constraint; their names appear in the violation message
_UNIQUE_FIELDS = ("mcc_mnc", "oauth2_client_id")


class PostgresPartnerRepo:
    def __init__(self, session: AsyncSession) -> None:
@@ -25,6 +30,49 @@ class PostgresPartnerRepo:
        rows = (await self._session.execute(stmt)).all()
        return [self._to_partner(row) for row in rows]

    async def find_by_mcc_mnc(self, mcc_mnc: str) -> PartnerOP | None:
        return await self._one(select(partner_ops).where(partner_ops.c.mcc_mnc == mcc_mnc))

    async def list_all(self) -> list[PartnerOP]:
        stmt = select(partner_ops).order_by(partner_ops.c.created_at)
        rows = (await self._session.execute(stmt)).all()
        return [self._to_partner(row) for row in rows]

    async def add(self, partner: PartnerOP) -> None:
        await self._write(insert(partner_ops).values(id=partner.id, **self._values(partner)))

    async def update(self, partner: PartnerOP) -> None:
        await self._write(
            update(partner_ops)
            .where(partner_ops.c.id == partner.id)
            .values(**self._values(partner))
        )

    async def _write(self, stmt: Any) -> None:
        try:
            await self._session.execute(stmt)
            await self._session.commit()
        except IntegrityError as error:
            await self._session.rollback()
            # a concurrent registration won the unique index between our read and this write
            field = next((f for f in _UNIQUE_FIELDS if f in str(error.orig)), None)
            if field is None:
                raise
            raise PartnerRegistrationConflict(field) from None

    @staticmethod
    def _values(partner: PartnerOP) -> dict[str, Any]:
        return {
            "name": partner.name,
            "mcc_mnc": partner.mcc_mnc,
            "oauth2_client_id": partner.oauth2_client_id,
            "base_url": partner.base_url,
            "our_client_id": partner.our_client_id,
            "our_client_secret_ref": partner.our_client_secret_ref,
            "token_endpoint": partner.token_endpoint,
            "status": partner.status,
        }

    async def _one(self, stmt: Select[tuple[object, ...]]) -> PartnerOP | None:
        row = (await self._session.execute(stmt)).one_or_none()
        return None if row is None else self._to_partner(row)
@@ -40,4 +88,5 @@ class PostgresPartnerRepo:
            our_client_secret_ref=row.our_client_secret_ref,
            token_endpoint=row.token_endpoint,
            base_url=row.base_url,
            name=row.name,
        )
+1 −0
Original line number Diff line number Diff line
@@ -35,6 +35,7 @@ partner_ops = Table(
    "partner_ops",
    metadata,
    Column("id", PGUUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()),
    Column("name", String(255), nullable=False),
    Column("mcc_mnc", String(10), nullable=False, unique=True),
    Column("oauth2_client_id", String(255), nullable=False, unique=True),
    Column("base_url", Text, nullable=False),
+27 −0
Original line number Diff line number Diff line
@@ -17,9 +17,11 @@ from federation_manager.domain.errors import (
    IdempotencyKeyReused,
    NetworkQueryNotApplicable,
    NoRouteMatched,
    PartnerCredentialsIncomplete,
    PartnerEndpointConfigurationError,
    PartnerNotActive,
    PartnerNotRegistered,
    PartnerRegistrationConflict,
    PartnerRejectedRequest,
    PartnerRequestFailed,
    PartnerResponseInvalid,
@@ -320,3 +322,28 @@ def register_exception_handlers(app: FastAPI) -> None:
            "Outbound federation is not correctly configured for the resolved partner.",
            request.url.path,
        )

    # Internal admin surface: the caller is our own operator, so the detail can name the field.
    @app.exception_handler(PartnerRegistrationConflict)
    async def _registration_conflict(
        request: Request, exc: PartnerRegistrationConflict
    ) -> JSONResponse:
        return problem(
            409,
            "partner-registration-conflict",
            "Partner Registration Conflict",
            f"A partner is already registered with this {exc.field}.",
            request.url.path,
        )

    @app.exception_handler(PartnerCredentialsIncomplete)
    async def _credentials_incomplete(
        request: Request, exc: PartnerCredentialsIncomplete
    ) -> JSONResponse:
        return problem(
            422,
            "partner-credentials-incomplete",
            "Partner Credentials Incomplete",
            str(exc),
            request.url.path,
        )
+114 −0
Original line number Diff line number Diff line
from typing import Annotated, Literal, Self
from urllib.parse import urlsplit
from uuid import UUID

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

from federation_manager.application.partners import PartnerRegistration, PartnerRegistryService
from federation_manager.dependencies import 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)]


def _http_url(value: str) -> str:
    # checked but stored as sent: HttpUrl would silently append a trailing slash
    parts = urlsplit(value)
    if parts.scheme not in ("http", "https") or not parts.netloc:
        raise ValueError("must be an absolute http(s) URL")
    return value


def _secret_ref(value: str) -> str:
    # a path to the mounted secret, never the secret itself (persistence model: no inline secrets)
    if not value.startswith("/"):
        raise ValueError("must be an absolute path to the mounted secret file")
    return value


Name = Annotated[str, Field(min_length=1, max_length=255)]
MccMnc = Annotated[str, Field(pattern=r"^[0-9]{3}-?[0-9]{2,3}$")]
ClientId = Annotated[str, Field(min_length=1, max_length=255)]
Url = Annotated[str, AfterValidator(_http_url)]
SecretRef = Annotated[str, AfterValidator(_secret_ref)]
Status = Literal["pending", "active", "suspended", "decommissioned"]


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

    name: Name
    mcc_mnc: MccMnc
    oauth2_client_id: ClientId = Field(
        description="Client id we issued to the partner in our Keycloak; matched to token azp"
    )
    base_url: Url = Field(description="Partner's EWBI federation endpoint")
    our_client_id: ClientId | None = Field(
        default=None, description="Client id the partner issued to us for outbound calls"
    )
    our_client_secret_ref: SecretRef | None = None
    token_endpoint: Url | None = Field(
        default=None, description="Partner's OAuth2 token endpoint for our outbound calls"
    )
    status: Literal["pending", "active"] = "pending"


class PartnerUpdate(BaseModel):
    """Partial update: fields left out are unchanged; null clears an optional field."""

    model_config = ConfigDict(extra="forbid")

    name: Name | None = None
    oauth2_client_id: ClientId | None = None
    base_url: Url | None = None
    our_client_id: ClientId | None = None
    our_client_secret_ref: SecretRef | None = None
    token_endpoint: Url | None = None
    status: Status | None = None

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


class PartnerView(BaseModel):
    id: UUID
    name: str
    mcc_mnc: str
    oauth2_client_id: str
    base_url: str | None
    our_client_id: str | None
    our_client_secret_ref: str | None
    token_endpoint: str | None
    status: Status

    @classmethod
    def of(cls, partner: PartnerOP) -> "PartnerView":
        return cls.model_validate(partner, from_attributes=True)


@router.post("", status_code=201, response_model=PartnerView)
async def register_partner(body: PartnerCreate, service: Service) -> PartnerView:
    return PartnerView.of(await service.register(PartnerRegistration(**body.model_dump())))


@router.get("", response_model=list[PartnerView])
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.patch("/{partner_op_id}", response_model=PartnerView)
async def update_partner(partner_op_id: UUID, body: PartnerUpdate, service: Service) -> PartnerView:
    changes = body.model_dump(exclude_unset=True)
    return PartnerView.of(await service.update(partner_op_id, changes))
+76 −0
Original line number Diff line number Diff line
from collections.abc import Callable
from dataclasses import dataclass, replace
from typing import Any
from uuid import UUID, uuid4

from federation_manager.domain.errors import (
    PartnerCredentialsIncomplete,
    PartnerNotRegistered,
    PartnerRegistrationConflict,
)
from federation_manager.domain.models import PartnerOP
from federation_manager.domain.ports import PartnerRepositoryPort


@dataclass(frozen=True)
class PartnerRegistration:
    """What operators exchange before federating (OPG.04 Table 371), plus FM's own keys."""

    name: str
    mcc_mnc: str
    oauth2_client_id: str
    base_url: str
    our_client_id: str | None = None
    our_client_secret_ref: str | None = None
    token_endpoint: str | None = None
    status: str = "pending"


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

    async def register(self, registration: PartnerRegistration) -> PartnerOP:
        _require_complete_credentials(registration)
        if await self._partners.find_by_mcc_mnc(registration.mcc_mnc) is not None:
            raise PartnerRegistrationConflict("mcc_mnc")
        await self._require_free_client_id(registration.oauth2_client_id)

        partner = PartnerOP(id=self._new_id(), **vars(registration))
        # a concurrent registration can still win the unique index; the repo reports it as 409
        await self._partners.add(partner)
        return partner

    async def get(self, partner_id: UUID) -> PartnerOP:
        partner = await self._partners.find_by_id(partner_id)
        if partner is None:
            raise PartnerNotRegistered(partner_id)
        return partner

    async def list(self) -> list[PartnerOP]:
        return await self._partners.list_all()

    async def update(self, partner_id: UUID, changes: dict[str, Any]) -> PartnerOP:
        partner = await self.get(partner_id)
        client_id = changes.get("oauth2_client_id")
        if client_id is not None and client_id != partner.oauth2_client_id:
            await self._require_free_client_id(client_id)

        updated = replace(partner, **changes)
        _require_complete_credentials(updated)
        await self._partners.update(updated)
        return updated

    async def _require_free_client_id(self, client_id: str) -> None:
        if await self._partners.find_by_oauth2_client_id(client_id) is not None:
            raise PartnerRegistrationConflict("oauth2_client_id")


def _require_complete_credentials(partner: PartnerRegistration | PartnerOP) -> None:
    # the outbound token grant needs all three; a partial set fails only on first use
    outbound = (partner.our_client_id, partner.our_client_secret_ref, partner.token_endpoint)
    if any(v is not None for v in outbound) and any(v is None for v in outbound):
        raise PartnerCredentialsIncomplete
Loading