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

feat(fm): app triple agreement mapping and LCM audit columns

GSMA InstallApp identifies the artefact by (appId, appVersion,
flavourId); the ADR-0018 agreement now maps that whole triple to a
local service_specification_id, with a separate api_families map for
network-capability APIs. federation_transactions gains the LCM audit
columns: idempotency_key (unique per partner+api_type when present),
request_fingerprint, external_resource_id and callback fields, plus
find_by_idempotency_key / mark_in_progress on the port.
parent 76dfe55e
Loading
Loading
Loading
Loading
+13 −3
Original line number Diff line number Diff line
@@ -4,7 +4,7 @@ from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession

from federation_manager.adapters.database.tables import federation_agreements as agreements
from federation_manager.domain.models import Agreement
from federation_manager.domain.models import Agreement, AppMapping


class PostgresAgreementRepo:
@@ -33,8 +33,18 @@ class PostgresAgreementRepo:
            partner_op_id=row.partner_op_id,
            permitted_api_types={str(v) for v in row.permitted_api_types},
            permitted_zone_ids={UUID(str(v)) for v in row.permitted_zone_ids},
            service_spec_mappings={
                str(k): UUID(str(v)) for k, v in row.service_spec_mappings.items()
            app_mappings=tuple(
                AppMapping(
                    app_id=str(entry["appId"]),
                    app_version=str(entry["appVersion"]),
                    flavour_id=str(entry["flavourId"]),
                    service_specification_id=UUID(str(entry["service_specification_id"])),
                )
                for entry in row.service_spec_mappings.get("apps", [])
            ),
            api_family_mappings={
                str(k): UUID(str(v))
                for k, v in row.service_spec_mappings.get("api_families", {}).items()
            },
            valid_from=row.valid_from,
            valid_until=row.valid_until,
+15 −0
Original line number Diff line number Diff line
from sqlalchemy import (
    CHAR,
    Boolean,
    Column,
    DateTime,
@@ -103,6 +104,12 @@ federation_transactions = Table(
    Column("operation_id", PGUUID(as_uuid=True)),
    Column("correlation_id", PGUUID(as_uuid=True)),
    Column("external_txn_id", String(255)),
    Column("idempotency_key", String(255)),
    Column("request_fingerprint", CHAR(64)),
    Column("external_resource_id", String(255)),
    Column("callback_url", Text),
    Column("callback_status", String(20)),
    Column("callback_attempts", Integer, nullable=False, server_default="0"),
    Column("api_type", String(100), nullable=False),
    Column("status", String(20), nullable=False, server_default="pending"),
    Column("request_summary", JSONB, nullable=False),
@@ -123,4 +130,12 @@ federation_transactions = Table(
    Index("idx_fed_tx_status", "status"),
    Index("idx_fed_tx_started", "started_at"),
    Index("idx_fed_tx_external_txn", "external_txn_id"),
    Index(
        "uq_fed_tx_idempotency_key",
        "partner_op_id",
        "api_type",
        "idempotency_key",
        unique=True,
        postgresql_where=text("idempotency_key IS NOT NULL"),
    ),
)
+54 −1
Original line number Diff line number Diff line
from datetime import datetime
from uuid import UUID

from sqlalchemy import insert, update
from sqlalchemy import insert, select, update
from sqlalchemy.ext.asyncio import AsyncSession

from federation_manager.adapters.database.tables import federation_transactions as transactions
@@ -25,6 +25,12 @@ class PostgresTransactionRepo:
                operation_id=transaction.operation_id,
                correlation_id=transaction.correlation_id,
                external_txn_id=transaction.external_txn_id,
                idempotency_key=transaction.idempotency_key,
                request_fingerprint=transaction.request_fingerprint,
                external_resource_id=transaction.external_resource_id,
                callback_url=transaction.callback_url,
                callback_status=transaction.callback_status,
                callback_attempts=transaction.callback_attempts,
                api_type=transaction.api_type,
                status=transaction.status,
                request_summary=transaction.request_summary,
@@ -36,6 +42,53 @@ class PostgresTransactionRepo:
        )
        await self._session.commit()

    async def find_by_idempotency_key(
        self, partner_id: UUID, api_type: str, idempotency_key: str
    ) -> FederationTransaction | None:
        row = (
            await self._session.execute(
                select(transactions).where(
                    transactions.c.partner_op_id == partner_id,
                    transactions.c.api_type == api_type,
                    transactions.c.idempotency_key == idempotency_key,
                )
            )
        ).one_or_none()
        if row is None:
            return None
        return FederationTransaction(
            id=row.id,
            partner_op_id=row.partner_op_id,
            direction=row.direction,
            api_type=row.api_type,
            status=row.status,
            request_summary=row.request_summary,
            started_at=row.started_at,
            agreement_id=row.agreement_id,
            federation_context_row_id=row.federation_context_row_id,
            external_txn_id=row.external_txn_id,
            idempotency_key=row.idempotency_key,
            request_fingerprint=row.request_fingerprint,
            external_resource_id=row.external_resource_id,
            callback_url=row.callback_url,
            callback_status=row.callback_status,
            callback_attempts=row.callback_attempts,
            operation_id=row.operation_id,
            correlation_id=row.correlation_id,
            federation_operation_id=row.federation_operation_id,
            response_summary=row.response_summary,
            error_detail=row.error_detail,
            completed_at=row.completed_at,
        )

    async def mark_in_progress(self, transaction_id: UUID) -> None:
        await self._session.execute(
            update(transactions)
            .where(transactions.c.id == transaction_id)
            .values(status="in_progress")
        )
        await self._session.commit()

    async def record_outcome(
        self,
        transaction_id: UUID,
+4 −2
Original line number Diff line number Diff line
@@ -51,17 +51,19 @@ class FederationAuthorizer:
        self._authenticator = authenticator
        self._agreements = AgreementChecker(agreement_repo)

    async def authorize(
    async def authorize_app(
        self,
        token: str,
        api_type: str,
        app_id: str,
        app_version: str,
        flavour_id: str,
        zone_id: UUID | None = None,
    ) -> AuthorizedRequest:
        partner = await self._authenticator.authenticate(token)
        agreement = await self._agreements.require(partner, api_type, zone_id)

        spec_id = agreement.resolve_spec_id(app_id)
        spec_id = agreement.resolve_app_spec(app_id, app_version, flavour_id)
        if spec_id is None:
            raise AgreementViolation

+30 −5
Original line number Diff line number Diff line
@@ -18,14 +18,23 @@ class PartnerOP:
        return self.status == "active"


@dataclass(frozen=True)
class AppMapping:
    app_id: str
    app_version: str
    flavour_id: str
    service_specification_id: UUID


@dataclass
class Agreement:
    id: UUID
    partner_op_id: UUID
    permitted_api_types: set[str]
    permitted_zone_ids: set[UUID]
    # partner appId -> local service_specification_id (ADR-0018)
    service_spec_mappings: dict[str, UUID]
    # both resolve a partner request to a local service_specification_id (ADR-0018)
    app_mappings: tuple[AppMapping, ...]
    api_family_mappings: dict[str, UUID]
    valid_from: datetime
    valid_until: datetime | None
    status: str  # draft | active | suspended | expired
@@ -43,8 +52,18 @@ class Agreement:
    def permits_zone(self, zone_id: UUID) -> bool:
        return zone_id in self.permitted_zone_ids

    def resolve_spec_id(self, app_id: str) -> UUID | None:
        return self.service_spec_mappings.get(app_id)
    def resolve_app_spec(self, app_id: str, app_version: str, flavour_id: str) -> UUID | None:
        for mapping in self.app_mappings:
            if (mapping.app_id, mapping.app_version, mapping.flavour_id) == (
                app_id,
                app_version,
                flavour_id,
            ):
                return mapping.service_specification_id
        return None

    def resolve_api_spec(self, api_type: str) -> UUID | None:
        return self.api_family_mappings.get(api_type)


@dataclass
@@ -105,7 +124,13 @@ class FederationTransaction:
    started_at: datetime
    agreement_id: UUID | None = None
    federation_context_row_id: UUID | None = None
    external_txn_id: str | None = None  # OPG.04 txnIdentifier / apiTxnId when the operation has one
    external_txn_id: str | None = None
    idempotency_key: str | None = None
    request_fingerprint: str | None = None
    external_resource_id: str | None = None
    callback_url: str | None = None
    callback_status: str | None = None
    callback_attempts: int = 0  # OPG.04 txnIdentifier / apiTxnId when the operation has one
    federation_operation_id: UUID | None = None
    operation_id: UUID | None = None  # never returned to partners
    correlation_id: UUID | None = None  # never returned to partners
Loading