Commit f581454b authored by Dimitrios Gogos's avatar Dimitrios Gogos
Browse files

feat: implement API client management with creation, deletion, and product...

feat: implement API client management with creation, deletion, and product association, including provisioning settings and routes
parent 304cc3e3
Loading
Loading
Loading
Loading
+0 −0

Empty file added.

+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)
+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
+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
@@ -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,
@@ -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")
+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
@@ -2,6 +2,7 @@

from pydantic import BaseModel

from portal_service.domain.api_products import ApiProductCatalog
from portal_service.domain.models import TokenClaims


@@ -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,
@@ -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