Loading keycloak/realm-import.json +34 −6 Changes for keycloak/realm-import.json: 34 added lines, 6 removed lines. Original line number Diff line number Diff line Loading @@ -7,6 +7,16 @@ "name": "fed-mgmt", "protocol": "openid-connect", "description": "fed-mgmt" }, { "id": "1d0f2129-dc8d-4675-8f44-5acb5154d3f6", "name": "fed-mgmt-notif", "protocol": "openid-connect", "description": "Access to the federation notification APIs (OPG.04 callbacks)", "attributes": { "include.in.token.scope": "true", "display.on.consent.screen": "false" } } ], "clients": [ Loading @@ -15,24 +25,42 @@ "enabled": true, "clientAuthenticatorType": "client-secret", "secret": "dd7vNwFqjNpYwaghlEwMbw10g0klWDHb", "redirectUris": ["http://localhost:8080/*"], "redirectUris": [ "http://localhost:8080/*" ], "publicClient": false, "directAccessGrantsEnabled": true, "serviceAccountsEnabled": true, "defaultClientScopes": ["fed-mgmt"], "webOrigins": ["*"] "defaultClientScopes": [ "fed-mgmt" ], "webOrigins": [ "*" ], "optionalClientScopes": [ "fed-mgmt-notif" ] }, { "clientId": "originating-op-2", "enabled": true, "clientAuthenticatorType": "client-secret", "secret": "2mhznERfWclLDuVojY77Lp4Qd2r4e8Ms", "redirectUris": ["http://localhost:8080/*"], "redirectUris": [ "http://localhost:8080/*" ], "publicClient": false, "directAccessGrantsEnabled": true, "serviceAccountsEnabled": true, "defaultClientScopes": ["fed-mgmt"], "webOrigins": ["*"] "defaultClientScopes": [ "fed-mgmt" ], "webOrigins": [ "*" ], "optionalClientScopes": [ "fed-mgmt-notif" ] } ] } src/federation_manager/api/ewbi/v1/callbacks.py 0 → 100644 +34 −0 Changes for src/federation_manager/api/ewbi/v1/callbacks.py: 34 added lines, 0 removed lines. Original line number Diff line number Diff line from typing import Annotated from fastapi import APIRouter, Depends from federation_manager.api.errors import EWBI_ERROR_RESPONSES from federation_manager.api.security import get_bearer_token from federation_manager.application.authentication import NOTIFICATION_SCOPE, PartnerAuthenticator from federation_manager.application.callbacks import PartnerStatusCallbackService from federation_manager.contracts.ewbi import PartnerStatusEvent from federation_manager.dependencies import ( get_partner_authenticator, get_partner_status_callback_service, ) from federation_manager.domain.ewbi import EWBI_BASE_PATH # Where partners call us back. OPG.04 leaves the URL to the originating OP; one router under # the partner-facing prefix keeps every callback on one authenticated path (ADR-0054). router = APIRouter(prefix=f"{EWBI_BASE_PATH}/callbacks", tags=["FederationCallbacks"]) @router.post( "/partner-status", operation_id="onPartnerStatusEvent", status_code=204, responses=EWBI_ERROR_RESPONSES, ) async def partner_status_event( body: PartnerStatusEvent, auth: Annotated[PartnerAuthenticator, Depends(get_partner_authenticator)], service: Annotated[PartnerStatusCallbackService, Depends(get_partner_status_callback_service)], token: Annotated[str, Depends(get_bearer_token)], ) -> None: partner = await auth.authenticate(token, scope=NOTIFICATION_SCOPE) await service.handle(partner, body) src/federation_manager/application/authentication.py +6 −2 Changes for src/federation_manager/application/authentication.py: 6 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -2,6 +2,9 @@ from federation_manager.domain.errors import AuthenticationFailed, PartnerNotAct from federation_manager.domain.models import PartnerOP from federation_manager.domain.ports import JwtValidatorPort, PartnerRepositoryPort EWBI_SCOPE = "fed-mgmt" NOTIFICATION_SCOPE = "fed-mgmt-notif" class PartnerAuthenticator: def __init__( Loading @@ -10,9 +13,10 @@ class PartnerAuthenticator: self._partner_repo = partner_repo self._jwt_validator = jwt_validator async def authenticate(self, token: str) -> PartnerOP: async def authenticate(self, token: str, scope: str = EWBI_SCOPE) -> PartnerOP: """Resolve a partner from its token. Callbacks pass NOTIFICATION_SCOPE (OPG.04 Sec. 9).""" claims = await self._jwt_validator.validate(token) if not claims.has_scope("fed-mgmt"): if not claims.has_scope(scope): raise AuthenticationFailed partner = await self._partner_repo.find_by_oauth2_client_id(claims.client_id) if partner is None: Loading src/federation_manager/application/callbacks.py 0 → 100644 +59 −0 Changes for src/federation_manager/application/callbacks.py: 59 added lines, 0 removed lines. Original line number Diff line number Diff line from federation_manager.application.federation import OUTBOUND, TERMINATED from federation_manager.contracts.ewbi import PartnerStatusEvent from federation_manager.core.logging import get_logger from federation_manager.domain.errors import FederationContextUnknown from federation_manager.domain.models import FederationContext, PartnerOP from federation_manager.domain.ports import FederationContextRepositoryPort logger = get_logger(__name__) # OPG.04 FederationStatus -> our lowercase column values (RD §K.1 federation_contexts.status). _STATUS: dict[str, str] = { "AVAILABLE": "available", "LOCKED": "locked", "NOT_AVAILABLE": "not_available", "TEMPORARY_FAILURE": "temporary_failure", "FAILED": "failed", } class PartnerStatusCallbackService: """onPartnerStatusEvent: the partner tells us something about a federation we created.""" def __init__(self, contexts: FederationContextRepositoryPort) -> None: self._contexts = contexts async def handle(self, partner: PartnerOP, event: PartnerStatusEvent) -> None: context = await self._outbound(partner, event.federation_context_id) if event.object_type != "FEDERATION" or event.federation_status is None: # Zone, network-code and service-API deltas need shared_resource_catalogues # (REQ-FED-04), which does not exist yet. Accept the notification rather than # make the partner retry a delivery we will never process. logger.info( "partner_status_callback_ignored", partner_op_id=str(partner.id), federation_context_id=event.federation_context_id, object_type=event.object_type, operation_type=event.operation_type, ) return status = _STATUS[event.federation_status] if status != context.status: await self._contexts.set_status(context.id, status) logger.info( "partner_status_callback_applied", partner_op_id=str(partner.id), federation_context_id=event.federation_context_id, status=status, ) async def _outbound(self, partner: PartnerOP, federation_context_id: str) -> FederationContext: # Outbound only: this callback reports on a federation we asked the partner for, and a # partner must not reach a context that is not theirs. context = await self._contexts.find_outbound(partner.id, federation_context_id) if context is None or context.status == TERMINATED: raise FederationContextUnknown(partner.id) assert context.direction == OUTBOUND return context src/federation_manager/contracts/ewbi.py +36 −0 Changes for src/federation_manager/contracts/ewbi.py: 36 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -237,6 +237,42 @@ class ZoneRegistrationResponseData(BaseModel): FederationStatus = Literal["FAILED", "TEMPORARY_FAILURE", "AVAILABLE", "LOCKED", "NOT_AVAILABLE"] # onPartnerStatusEvent (OPG.04 CreateFederation callback). Only the fields FM acts on are # modelled; the rest of the payload (zone, network-code and service-API deltas) is ignored # until the zone catalogue exists. PartnerStatusObject = Literal[ "FEDERATION", "ZONES", "EDGE_DISCOVERY_SERVICE", "LCM_SERVICE", "MOBILE_NETWORK_CODES", "FIXED_NETWORK_CODES", "SERVICE_APIS", ] PartnerStatusOperation = Literal["STATUS", "UPDATE", "ADD", "REMOVE"] class PartnerStatusEvent(BaseModel): model_config = ConfigDict(extra="ignore", populate_by_name=True) federation_context_id: str = Field( validation_alias="federationContextId", serialization_alias="federationContextId" ) object_type: PartnerStatusObject = Field( validation_alias="objectType", serialization_alias="objectType" ) operation_type: PartnerStatusOperation = Field( validation_alias="operationType", serialization_alias="operationType" ) federation_status: FederationStatus | None = Field( default=None, validation_alias="federationStatus", serialization_alias="federationStatus", ) modification_date: datetime = Field( validation_alias="modificationDate", serialization_alias="modificationDate" ) class FederationHealthInfo(BaseModel): model_config = ConfigDict(populate_by_name=True) Loading Loading
keycloak/realm-import.json +34 −6 Changes for keycloak/realm-import.json: 34 added lines, 6 removed lines. Original line number Diff line number Diff line Loading @@ -7,6 +7,16 @@ "name": "fed-mgmt", "protocol": "openid-connect", "description": "fed-mgmt" }, { "id": "1d0f2129-dc8d-4675-8f44-5acb5154d3f6", "name": "fed-mgmt-notif", "protocol": "openid-connect", "description": "Access to the federation notification APIs (OPG.04 callbacks)", "attributes": { "include.in.token.scope": "true", "display.on.consent.screen": "false" } } ], "clients": [ Loading @@ -15,24 +25,42 @@ "enabled": true, "clientAuthenticatorType": "client-secret", "secret": "dd7vNwFqjNpYwaghlEwMbw10g0klWDHb", "redirectUris": ["http://localhost:8080/*"], "redirectUris": [ "http://localhost:8080/*" ], "publicClient": false, "directAccessGrantsEnabled": true, "serviceAccountsEnabled": true, "defaultClientScopes": ["fed-mgmt"], "webOrigins": ["*"] "defaultClientScopes": [ "fed-mgmt" ], "webOrigins": [ "*" ], "optionalClientScopes": [ "fed-mgmt-notif" ] }, { "clientId": "originating-op-2", "enabled": true, "clientAuthenticatorType": "client-secret", "secret": "2mhznERfWclLDuVojY77Lp4Qd2r4e8Ms", "redirectUris": ["http://localhost:8080/*"], "redirectUris": [ "http://localhost:8080/*" ], "publicClient": false, "directAccessGrantsEnabled": true, "serviceAccountsEnabled": true, "defaultClientScopes": ["fed-mgmt"], "webOrigins": ["*"] "defaultClientScopes": [ "fed-mgmt" ], "webOrigins": [ "*" ], "optionalClientScopes": [ "fed-mgmt-notif" ] } ] }
src/federation_manager/api/ewbi/v1/callbacks.py 0 → 100644 +34 −0 Changes for src/federation_manager/api/ewbi/v1/callbacks.py: 34 added lines, 0 removed lines. Original line number Diff line number Diff line from typing import Annotated from fastapi import APIRouter, Depends from federation_manager.api.errors import EWBI_ERROR_RESPONSES from federation_manager.api.security import get_bearer_token from federation_manager.application.authentication import NOTIFICATION_SCOPE, PartnerAuthenticator from federation_manager.application.callbacks import PartnerStatusCallbackService from federation_manager.contracts.ewbi import PartnerStatusEvent from federation_manager.dependencies import ( get_partner_authenticator, get_partner_status_callback_service, ) from federation_manager.domain.ewbi import EWBI_BASE_PATH # Where partners call us back. OPG.04 leaves the URL to the originating OP; one router under # the partner-facing prefix keeps every callback on one authenticated path (ADR-0054). router = APIRouter(prefix=f"{EWBI_BASE_PATH}/callbacks", tags=["FederationCallbacks"]) @router.post( "/partner-status", operation_id="onPartnerStatusEvent", status_code=204, responses=EWBI_ERROR_RESPONSES, ) async def partner_status_event( body: PartnerStatusEvent, auth: Annotated[PartnerAuthenticator, Depends(get_partner_authenticator)], service: Annotated[PartnerStatusCallbackService, Depends(get_partner_status_callback_service)], token: Annotated[str, Depends(get_bearer_token)], ) -> None: partner = await auth.authenticate(token, scope=NOTIFICATION_SCOPE) await service.handle(partner, body)
src/federation_manager/application/authentication.py +6 −2 Changes for src/federation_manager/application/authentication.py: 6 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -2,6 +2,9 @@ from federation_manager.domain.errors import AuthenticationFailed, PartnerNotAct from federation_manager.domain.models import PartnerOP from federation_manager.domain.ports import JwtValidatorPort, PartnerRepositoryPort EWBI_SCOPE = "fed-mgmt" NOTIFICATION_SCOPE = "fed-mgmt-notif" class PartnerAuthenticator: def __init__( Loading @@ -10,9 +13,10 @@ class PartnerAuthenticator: self._partner_repo = partner_repo self._jwt_validator = jwt_validator async def authenticate(self, token: str) -> PartnerOP: async def authenticate(self, token: str, scope: str = EWBI_SCOPE) -> PartnerOP: """Resolve a partner from its token. Callbacks pass NOTIFICATION_SCOPE (OPG.04 Sec. 9).""" claims = await self._jwt_validator.validate(token) if not claims.has_scope("fed-mgmt"): if not claims.has_scope(scope): raise AuthenticationFailed partner = await self._partner_repo.find_by_oauth2_client_id(claims.client_id) if partner is None: Loading
src/federation_manager/application/callbacks.py 0 → 100644 +59 −0 Changes for src/federation_manager/application/callbacks.py: 59 added lines, 0 removed lines. Original line number Diff line number Diff line from federation_manager.application.federation import OUTBOUND, TERMINATED from federation_manager.contracts.ewbi import PartnerStatusEvent from federation_manager.core.logging import get_logger from federation_manager.domain.errors import FederationContextUnknown from federation_manager.domain.models import FederationContext, PartnerOP from federation_manager.domain.ports import FederationContextRepositoryPort logger = get_logger(__name__) # OPG.04 FederationStatus -> our lowercase column values (RD §K.1 federation_contexts.status). _STATUS: dict[str, str] = { "AVAILABLE": "available", "LOCKED": "locked", "NOT_AVAILABLE": "not_available", "TEMPORARY_FAILURE": "temporary_failure", "FAILED": "failed", } class PartnerStatusCallbackService: """onPartnerStatusEvent: the partner tells us something about a federation we created.""" def __init__(self, contexts: FederationContextRepositoryPort) -> None: self._contexts = contexts async def handle(self, partner: PartnerOP, event: PartnerStatusEvent) -> None: context = await self._outbound(partner, event.federation_context_id) if event.object_type != "FEDERATION" or event.federation_status is None: # Zone, network-code and service-API deltas need shared_resource_catalogues # (REQ-FED-04), which does not exist yet. Accept the notification rather than # make the partner retry a delivery we will never process. logger.info( "partner_status_callback_ignored", partner_op_id=str(partner.id), federation_context_id=event.federation_context_id, object_type=event.object_type, operation_type=event.operation_type, ) return status = _STATUS[event.federation_status] if status != context.status: await self._contexts.set_status(context.id, status) logger.info( "partner_status_callback_applied", partner_op_id=str(partner.id), federation_context_id=event.federation_context_id, status=status, ) async def _outbound(self, partner: PartnerOP, federation_context_id: str) -> FederationContext: # Outbound only: this callback reports on a federation we asked the partner for, and a # partner must not reach a context that is not theirs. context = await self._contexts.find_outbound(partner.id, federation_context_id) if context is None or context.status == TERMINATED: raise FederationContextUnknown(partner.id) assert context.direction == OUTBOUND return context
src/federation_manager/contracts/ewbi.py +36 −0 Changes for src/federation_manager/contracts/ewbi.py: 36 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -237,6 +237,42 @@ class ZoneRegistrationResponseData(BaseModel): FederationStatus = Literal["FAILED", "TEMPORARY_FAILURE", "AVAILABLE", "LOCKED", "NOT_AVAILABLE"] # onPartnerStatusEvent (OPG.04 CreateFederation callback). Only the fields FM acts on are # modelled; the rest of the payload (zone, network-code and service-API deltas) is ignored # until the zone catalogue exists. PartnerStatusObject = Literal[ "FEDERATION", "ZONES", "EDGE_DISCOVERY_SERVICE", "LCM_SERVICE", "MOBILE_NETWORK_CODES", "FIXED_NETWORK_CODES", "SERVICE_APIS", ] PartnerStatusOperation = Literal["STATUS", "UPDATE", "ADD", "REMOVE"] class PartnerStatusEvent(BaseModel): model_config = ConfigDict(extra="ignore", populate_by_name=True) federation_context_id: str = Field( validation_alias="federationContextId", serialization_alias="federationContextId" ) object_type: PartnerStatusObject = Field( validation_alias="objectType", serialization_alias="objectType" ) operation_type: PartnerStatusOperation = Field( validation_alias="operationType", serialization_alias="operationType" ) federation_status: FederationStatus | None = Field( default=None, validation_alias="federationStatus", serialization_alias="federationStatus", ) modification_date: datetime = Field( validation_alias="modificationDate", serialization_alias="modificationDate" ) class FederationHealthInfo(BaseModel): model_config = ConfigDict(populate_by_name=True) Loading