Loading src/portal_service/api/api_clients/__init__.py 0 → 100644 +0 −0 Empty file added. src/portal_service/api/api_clients/routes.py 0 → 100644 +110 −0 Changes for src/portal_service/api/api_clients/routes.py: 110 added lines, 0 removed lines. Original line number Diff line number Diff line from collections.abc import Iterator from contextlib import contextmanager from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Response, status from portal_service.api.api_clients.schemas import ( ApiClientResponse, ApiProductResponse, ConnectionResponse, CreateApiClientRequest, CreatedApiClientResponse, ) from portal_service.api.dependencies import ( ApiClientServiceDep, ApiProductsDep, AppProviderDep, ProvisioningDep, SettingsDep, require_app_provider, ) from portal_service.domain.errors import ( ApiClientLimitReachedError, ApiClientNameTakenError, ApiClientNotFoundError, CreatorNotAllowedError, IdentityProviderUnavailableError, ProductNotHeldError, ) api_clients_router = APIRouter(prefix="/api/api-clients", tags=["api-clients"]) api_products_router = APIRouter(prefix="/api/api-products", tags=["api-clients"]) _REFUSALS = { ApiClientLimitReachedError: status.HTTP_409_CONFLICT, ApiClientNameTakenError: status.HTTP_409_CONFLICT, CreatorNotAllowedError: status.HTTP_403_FORBIDDEN, ProductNotHeldError: status.HTTP_403_FORBIDDEN, ApiClientNotFoundError: status.HTTP_404_NOT_FOUND, } @contextmanager def _answering_refusals() -> Iterator[None]: try: yield except tuple(_REFUSALS) as exc: raise HTTPException(_REFUSALS[type(exc)], str(exc)) from exc except IdentityProviderUnavailableError as exc: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, "The identity provider is unavailable" ) from exc @api_clients_router.get("") async def list_api_clients( user: AppProviderDep, service: ApiClientServiceDep ) -> list[ApiClientResponse]: return [ ApiClientResponse.of(details.api_client, details.products) for details in await service.list_for(user.sub) ] @api_clients_router.get("/connection", dependencies=[Depends(require_app_provider)]) async def connection(provisioning: ProvisioningDep, settings: SettingsDep) -> ConnectionResponse: return ConnectionResponse( token_endpoint=provisioning.oidc.token_endpoint, oeg_base_url=settings.oeg.public_base_url or None, ) @api_products_router.get("") async def list_api_products( user: AppProviderDep, catalog: ApiProductsDep ) -> list[ApiProductResponse]: held = catalog.products_in(user.api_scopes) return [ ApiProductResponse( name=product.name, scopes=sorted(product.scopes), held=product.name in held ) for product in catalog.products ] @api_clients_router.post("", status_code=status.HTTP_201_CREATED) async def create_api_client( body: CreateApiClientRequest, response: Response, user: AppProviderDep, service: ApiClientServiceDep, ) -> CreatedApiClientResponse: # The secret is in this response only: nothing may cache it. response.headers["Cache-Control"] = "no-store" with _answering_refusals(): new = await service.create( creator=user.sub, name=body.name, description=body.description, products=body.products, ) return CreatedApiClientResponse.of(new.api_client, new.products, client_secret=new.secret) @api_clients_router.delete("/{api_client_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_api_client( api_client_id: UUID, user: AppProviderDep, service: ApiClientServiceDep ) -> None: with _answering_refusals(): await service.delete(creator=user.sub, api_client_id=api_client_id) src/portal_service/api/api_clients/schemas.py 0 → 100644 +60 −0 Changes for src/portal_service/api/api_clients/schemas.py: 60 added lines, 0 removed lines. Original line number Diff line number Diff line from datetime import datetime from typing import Any, Self from uuid import UUID from pydantic import BaseModel, Field from portal_service.domain.models import ApiClient, DisabledBy, OperationalState class CreateApiClientRequest(BaseModel): # The limits are the column sizes of the api_clients table. name: str = Field(min_length=1, max_length=100) description: str = Field(default="", max_length=500) products: frozenset[str] class ApiClientResponse(BaseModel): id: UUID name: str description: str client_id: str products: list[str] state: OperationalState disabled_by: DisabledBy | None created_at: datetime | None @classmethod def of(cls, api_client: ApiClient, products: frozenset[str], **more: Any) -> Self: return cls( id=api_client.id, name=api_client.name, description=api_client.description, client_id=api_client.keycloak_client_id, products=sorted(products), state=api_client.operational_state, disabled_by=api_client.disabled_by, created_at=api_client.created_at, **more, ) class ConnectionResponse(BaseModel): """Where an API client gets its token and what it calls with it.""" token_endpoint: str # None when this deployment has not said where developers reach OEG. oeg_base_url: str | None class ApiProductResponse(BaseModel): name: str scopes: list[str] # Whether the caller's own token carries the product, so the form can offer it. held: bool class CreatedApiClientResponse(ApiClientResponse): """Only the create response carries the secret: it is shown once and never stored.""" client_secret: str src/portal_service/api/auth/routes.py +3 −2 Changes for src/portal_service/api/auth/routes.py: 3 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -23,6 +23,7 @@ from starlette.concurrency import run_in_threadpool from portal_service.api.auth.schemas import CurrentUser, LogoutResponse from portal_service.api.dependencies import ( ApiProductsDep, CurrentUserDep, SettingsDep, TokenVerifierDep, Loading Loading @@ -210,8 +211,8 @@ def _session_response( @auth_router.get("/me") async def me(user: CurrentUserDep) -> CurrentUser: return CurrentUser.from_claims(user) async def me(user: CurrentUserDep, catalog: ApiProductsDep) -> CurrentUser: return CurrentUser.from_claims(user, catalog) @auth_router.post("/logout") Loading src/portal_service/api/auth/schemas.py +5 −1 Changes for src/portal_service/api/auth/schemas.py: 5 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -2,6 +2,7 @@ from pydantic import BaseModel from portal_service.domain.api_products import ApiProductCatalog from portal_service.domain.models import TokenClaims Loading @@ -12,9 +13,11 @@ class CurrentUser(BaseModel): roles: list[str] = [] organization: str | None = None scopes: list[str] = [] # The API products whose every scope the token carries. products: list[str] = [] @classmethod def from_claims(cls, claims: TokenClaims) -> "CurrentUser": def from_claims(cls, claims: TokenClaims, catalog: ApiProductCatalog) -> "CurrentUser": return cls( subject=claims.sub, username=claims.preferred_username, Loading @@ -23,6 +26,7 @@ class CurrentUser(BaseModel): roles=sorted(claims.roles), organization=claims.organization, scopes=sorted(claims.api_scopes), products=sorted(catalog.products_in(claims.api_scopes)), ) Loading Loading
src/portal_service/api/api_clients/routes.py 0 → 100644 +110 −0 Changes for src/portal_service/api/api_clients/routes.py: 110 added lines, 0 removed lines. Original line number Diff line number Diff line from collections.abc import Iterator from contextlib import contextmanager from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Response, status from portal_service.api.api_clients.schemas import ( ApiClientResponse, ApiProductResponse, ConnectionResponse, CreateApiClientRequest, CreatedApiClientResponse, ) from portal_service.api.dependencies import ( ApiClientServiceDep, ApiProductsDep, AppProviderDep, ProvisioningDep, SettingsDep, require_app_provider, ) from portal_service.domain.errors import ( ApiClientLimitReachedError, ApiClientNameTakenError, ApiClientNotFoundError, CreatorNotAllowedError, IdentityProviderUnavailableError, ProductNotHeldError, ) api_clients_router = APIRouter(prefix="/api/api-clients", tags=["api-clients"]) api_products_router = APIRouter(prefix="/api/api-products", tags=["api-clients"]) _REFUSALS = { ApiClientLimitReachedError: status.HTTP_409_CONFLICT, ApiClientNameTakenError: status.HTTP_409_CONFLICT, CreatorNotAllowedError: status.HTTP_403_FORBIDDEN, ProductNotHeldError: status.HTTP_403_FORBIDDEN, ApiClientNotFoundError: status.HTTP_404_NOT_FOUND, } @contextmanager def _answering_refusals() -> Iterator[None]: try: yield except tuple(_REFUSALS) as exc: raise HTTPException(_REFUSALS[type(exc)], str(exc)) from exc except IdentityProviderUnavailableError as exc: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, "The identity provider is unavailable" ) from exc @api_clients_router.get("") async def list_api_clients( user: AppProviderDep, service: ApiClientServiceDep ) -> list[ApiClientResponse]: return [ ApiClientResponse.of(details.api_client, details.products) for details in await service.list_for(user.sub) ] @api_clients_router.get("/connection", dependencies=[Depends(require_app_provider)]) async def connection(provisioning: ProvisioningDep, settings: SettingsDep) -> ConnectionResponse: return ConnectionResponse( token_endpoint=provisioning.oidc.token_endpoint, oeg_base_url=settings.oeg.public_base_url or None, ) @api_products_router.get("") async def list_api_products( user: AppProviderDep, catalog: ApiProductsDep ) -> list[ApiProductResponse]: held = catalog.products_in(user.api_scopes) return [ ApiProductResponse( name=product.name, scopes=sorted(product.scopes), held=product.name in held ) for product in catalog.products ] @api_clients_router.post("", status_code=status.HTTP_201_CREATED) async def create_api_client( body: CreateApiClientRequest, response: Response, user: AppProviderDep, service: ApiClientServiceDep, ) -> CreatedApiClientResponse: # The secret is in this response only: nothing may cache it. response.headers["Cache-Control"] = "no-store" with _answering_refusals(): new = await service.create( creator=user.sub, name=body.name, description=body.description, products=body.products, ) return CreatedApiClientResponse.of(new.api_client, new.products, client_secret=new.secret) @api_clients_router.delete("/{api_client_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_api_client( api_client_id: UUID, user: AppProviderDep, service: ApiClientServiceDep ) -> None: with _answering_refusals(): await service.delete(creator=user.sub, api_client_id=api_client_id)
src/portal_service/api/api_clients/schemas.py 0 → 100644 +60 −0 Changes for src/portal_service/api/api_clients/schemas.py: 60 added lines, 0 removed lines. Original line number Diff line number Diff line from datetime import datetime from typing import Any, Self from uuid import UUID from pydantic import BaseModel, Field from portal_service.domain.models import ApiClient, DisabledBy, OperationalState class CreateApiClientRequest(BaseModel): # The limits are the column sizes of the api_clients table. name: str = Field(min_length=1, max_length=100) description: str = Field(default="", max_length=500) products: frozenset[str] class ApiClientResponse(BaseModel): id: UUID name: str description: str client_id: str products: list[str] state: OperationalState disabled_by: DisabledBy | None created_at: datetime | None @classmethod def of(cls, api_client: ApiClient, products: frozenset[str], **more: Any) -> Self: return cls( id=api_client.id, name=api_client.name, description=api_client.description, client_id=api_client.keycloak_client_id, products=sorted(products), state=api_client.operational_state, disabled_by=api_client.disabled_by, created_at=api_client.created_at, **more, ) class ConnectionResponse(BaseModel): """Where an API client gets its token and what it calls with it.""" token_endpoint: str # None when this deployment has not said where developers reach OEG. oeg_base_url: str | None class ApiProductResponse(BaseModel): name: str scopes: list[str] # Whether the caller's own token carries the product, so the form can offer it. held: bool class CreatedApiClientResponse(ApiClientResponse): """Only the create response carries the secret: it is shown once and never stored.""" client_secret: str
src/portal_service/api/auth/routes.py +3 −2 Changes for src/portal_service/api/auth/routes.py: 3 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -23,6 +23,7 @@ from starlette.concurrency import run_in_threadpool from portal_service.api.auth.schemas import CurrentUser, LogoutResponse from portal_service.api.dependencies import ( ApiProductsDep, CurrentUserDep, SettingsDep, TokenVerifierDep, Loading Loading @@ -210,8 +211,8 @@ def _session_response( @auth_router.get("/me") async def me(user: CurrentUserDep) -> CurrentUser: return CurrentUser.from_claims(user) async def me(user: CurrentUserDep, catalog: ApiProductsDep) -> CurrentUser: return CurrentUser.from_claims(user, catalog) @auth_router.post("/logout") Loading
src/portal_service/api/auth/schemas.py +5 −1 Changes for src/portal_service/api/auth/schemas.py: 5 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -2,6 +2,7 @@ from pydantic import BaseModel from portal_service.domain.api_products import ApiProductCatalog from portal_service.domain.models import TokenClaims Loading @@ -12,9 +13,11 @@ class CurrentUser(BaseModel): roles: list[str] = [] organization: str | None = None scopes: list[str] = [] # The API products whose every scope the token carries. products: list[str] = [] @classmethod def from_claims(cls, claims: TokenClaims) -> "CurrentUser": def from_claims(cls, claims: TokenClaims, catalog: ApiProductCatalog) -> "CurrentUser": return cls( subject=claims.sub, username=claims.preferred_username, Loading @@ -23,6 +26,7 @@ class CurrentUser(BaseModel): roles=sorted(claims.roles), organization=claims.organization, scopes=sorted(claims.api_scopes), products=sorted(catalog.products_in(claims.api_scopes)), ) Loading