Loading src/federation_manager/adapters/database/partner_repo.py +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: Loading @@ -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) Loading @@ -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, ) src/federation_manager/adapters/database/tables.py +1 −0 Original line number Diff line number Diff line Loading @@ -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), Loading src/federation_manager/api/errors.py +27 −0 Original line number Diff line number Diff line Loading @@ -17,9 +17,11 @@ from federation_manager.domain.errors import ( IdempotencyKeyReused, NetworkQueryNotApplicable, NoRouteMatched, PartnerCredentialsIncomplete, PartnerEndpointConfigurationError, PartnerNotActive, PartnerNotRegistered, PartnerRegistrationConflict, PartnerRejectedRequest, PartnerRequestFailed, PartnerResponseInvalid, Loading Loading @@ -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, ) src/federation_manager/api/internal/partners.py 0 → 100644 +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)) src/federation_manager/application/partners.py 0 → 100644 +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
src/federation_manager/adapters/database/partner_repo.py +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: Loading @@ -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) Loading @@ -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, )
src/federation_manager/adapters/database/tables.py +1 −0 Original line number Diff line number Diff line Loading @@ -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), Loading
src/federation_manager/api/errors.py +27 −0 Original line number Diff line number Diff line Loading @@ -17,9 +17,11 @@ from federation_manager.domain.errors import ( IdempotencyKeyReused, NetworkQueryNotApplicable, NoRouteMatched, PartnerCredentialsIncomplete, PartnerEndpointConfigurationError, PartnerNotActive, PartnerNotRegistered, PartnerRegistrationConflict, PartnerRejectedRequest, PartnerRequestFailed, PartnerResponseInvalid, Loading Loading @@ -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, )
src/federation_manager/api/internal/partners.py 0 → 100644 +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))
src/federation_manager/application/partners.py 0 → 100644 +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