Commit 304cc3e3 authored by Dimitrios Gogos's avatar Dimitrios Gogos
Browse files

feat: implement ApiClientService with create and delete functionality,...

feat: implement ApiClientService with create and delete functionality, including error handling and in-memory tests
parent 7d71f8fc
Loading
Loading
Loading
Loading
+167 −0
Changes for src/portal_service/application/api_client_service.py: 167 added lines, 0 removed lines.
Original line number Diff line number Diff line
"""
API clients: what a developer may do with the applications they register.

The routes deal with sessions and HTTP; the rules live here. Keycloak holds the clients,
their secrets and the product roles; the database holds who owns what and why.
"""

from datetime import UTC, datetime
from uuid import UUID, uuid4

import structlog
from pydantic import BaseModel, Field

from portal_service.application.crypto import decrypt, encrypt
from portal_service.domain.api_products import ApiProductCatalog
from portal_service.domain.errors import (
    ApiClientLimitReachedError,
    ApiClientNameTakenError,
    ApiClientNotFoundError,
    CreatorNotAllowedError,
    ProductNotHeldError,
    RegistrationTokenRejectedError,
)
from portal_service.domain.models import (
    ApiClient,
    ApiClientEntitlement,
    DecidedBy,
    EntitlementState,
)
from portal_service.domain.ports.client_registry import ClientRegistry, ClientSpec
from portal_service.domain.ports.database.api_client_entitlements import (
    ApiClientEntitlementRepository,
)
from portal_service.domain.ports.database.api_clients import ApiClientRepository
from portal_service.domain.ports.identity_directory import IdentityDirectory
from portal_service.domain.ports.product_roles import ProductRoleAssigner

logger: structlog.BoundLogger = structlog.get_logger(__name__)


class NewApiClient(BaseModel):
    """What creating a client returns. The secret is shown once and never stored."""

    api_client: ApiClient
    secret: str = Field(repr=False)


class ApiClientService:
    def __init__(
        self,
        *,
        clients: ApiClientRepository,
        entitlements: ApiClientEntitlementRepository,
        registry: ClientRegistry,
        roles: ProductRoleAssigner,
        directory: IdentityDirectory,
        catalog: ApiProductCatalog,
        encryption_key: bytes,
        max_clients_per_creator: int,
    ) -> None:
        self._clients = clients
        self._entitlements = entitlements
        self._registry = registry
        self._roles = roles
        self._directory = directory
        self._catalog = catalog
        self._encryption_key = encryption_key
        self._max_clients = max_clients_per_creator

    async def create(
        self, *, creator: str, name: str, description: str, products: frozenset[str]
    ) -> NewApiClient:
        existing = await self._clients.list_by_creator(creator)
        if len(existing) >= self._max_clients:
            raise ApiClientLimitReachedError(f"At most {self._max_clients} API clients each")
        if any(client.name == name for client in existing):
            raise ApiClientNameTakenError(f"You already have an API client named {name!r}")

        user = await self._directory.get_user(creator)
        if user is None or not user.enabled:
            raise CreatorNotAllowedError("The creator is unknown or disabled")
        missing = products - user.products
        if missing:
            raise ProductNotHeldError(f"Not granted to you: {', '.join(sorted(missing))}")

        owner = user.organization or creator
        keycloak_client_id = f"api-{uuid4().hex[:8]}"
        registered = await self._registry.register(
            ClientSpec(
                keycloak_client_id=keycloak_client_id,
                name=name,
                description=description,
                scopes=frozenset(self._catalog.scopes_for(products)),
                organization=owner,
                app_provider_id=creator,
            )
        )
        api_client = ApiClient(
            id=uuid4(),
            keycloak_client_id=keycloak_client_id,
            name=name,
            description=description,
            created_by=creator,
            application_owner_id=owner,
            registration_token=encrypt(
                registered.registration_token.encode(), self._encryption_key
            ),
        )
        try:
            saved = await self._store(api_client, products)
        except Exception:
            await self._take_back(keycloak_client_id, registered.registration_token)
            raise
        return NewApiClient(api_client=saved, secret=registered.secret)

    async def delete(self, *, creator: str, api_client_id: UUID) -> None:
        api_client = await self._clients.get_by_id(api_client_id)
        if api_client is None or api_client.created_by != creator or api_client.deleted_at:
            raise ApiClientNotFoundError(f"No API client {api_client_id}")

        token = decrypt(api_client.registration_token, self._encryption_key).decode()
        try:
            await self._registry.delete(api_client.keycloak_client_id, token)
        except RegistrationTokenRejectedError:
            # Disabled or removed in the Keycloak console: nothing more can be done there.
            logger.warning(
                "keycloak client left behind", keycloak_client_id=api_client.keycloak_client_id
            )

        now = datetime.now(UTC)
        for entitlement in await self._entitlements.list_by_client(api_client.id):
            if entitlement.state is EntitlementState.APPROVED:
                await self._entitlements.save(
                    entitlement.model_copy(
                        update={
                            "state": EntitlementState.REVOKED,
                            "decided_by": DecidedBy.CREATOR,
                            "decided_at": now,
                        }
                    )
                )
        await self._clients.save(api_client.model_copy(update={"deleted_at": now}))

    async def _store(self, api_client: ApiClient, products: frozenset[str]) -> ApiClient:
        for product in sorted(products):
            await self._roles.grant(api_client.keycloak_client_id, product)
        saved = await self._clients.save(api_client)
        for product in sorted(products):
            await self._entitlements.save(
                ApiClientEntitlement(
                    id=uuid4(),
                    api_client_id=saved.id,
                    product=product,
                    state=EntitlementState.APPROVED,
                    decided_by=DecidedBy.AUTO,
                    decided_at=datetime.now(UTC),
                )
            )
        return saved

    async def _take_back(self, keycloak_client_id: str, registration_token: str) -> None:
        try:
            await self._registry.delete(keycloak_client_id, registration_token)
        except Exception as exc:
            logger.error(
                "orphan keycloak client", keycloak_client_id=keycloak_client_id, error=str(exc)
            )
+25 −0
Changes for src/portal_service/domain/errors.py: 25 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -49,3 +49,28 @@ class RegistrationTokenRejectedError(Exception):
# The client has no service-account user: it was never registered, or it was deleted.
class ServiceAccountNotFoundError(Exception):
    pass


# The creator already has as many API clients as allowed.
class ApiClientLimitReachedError(Exception):
    pass


# The creator already has an API client with this name.
class ApiClientNameTakenError(Exception):
    pass


# The creator is unknown to Keycloak or disabled there.
class CreatorNotAllowedError(Exception):
    pass


# The request asks for API products the creator does not hold.
class ProductNotHeldError(Exception):
    pass


# No such API client for this creator: unknown, someone else's, or already deleted.
class ApiClientNotFoundError(Exception):
    pass
+107 −2
Changes for tests/fakes.py: 107 added lines, 2 removed lines.
Original line number Diff line number Diff line
"""In-memory stand-ins for the ports, for tests that exercise routes."""

from datetime import datetime

from portal_service.domain.models import IssuedTokens, LoginAttempt, LoginSession
from uuid import UUID

from portal_service.domain.models import (
    ApiClient,
    ApiClientEntitlement,
    IssuedTokens,
    LoginAttempt,
    LoginSession,
)
from portal_service.domain.ports.client_registry import (
    ClientRegistry,
    ClientSpec,
    RegisteredClient,
)
from portal_service.domain.ports.database.api_client_entitlements import (
    ApiClientEntitlementRepository,
)
from portal_service.domain.ports.database.api_clients import ApiClientRepository
from portal_service.domain.ports.database.login_attempts import LoginAttemptRepository
from portal_service.domain.ports.database.login_sessions import LoginSessionRepository
from portal_service.domain.ports.identity_directory import DirectoryUser, IdentityDirectory
from portal_service.domain.ports.identity_provider import IdentityProvider
from portal_service.domain.ports.product_roles import ProductRoleAssigner


class FakeApiClientRepository(ApiClientRepository):
    def __init__(self) -> None:
        self.rows: dict[UUID, ApiClient] = {}
        self.fail_on_save = False

    async def save(self, api_client: ApiClient) -> ApiClient:
        if self.fail_on_save:
            raise RuntimeError("database is down")
        self.rows[api_client.id] = api_client
        return api_client

    async def get_by_id(self, api_client_id: UUID) -> ApiClient | None:
        return self.rows.get(api_client_id)

    async def list_by_creator(self, created_by: str) -> list[ApiClient]:
        return [
            row
            for row in self.rows.values()
            if row.created_by == created_by and row.deleted_at is None
        ]


class FakeApiClientEntitlementRepository(ApiClientEntitlementRepository):
    def __init__(self) -> None:
        self.rows: list[ApiClientEntitlement] = []

    async def save(self, entitlement: ApiClientEntitlement) -> ApiClientEntitlement:
        self.rows = [row for row in self.rows if row.id != entitlement.id] + [entitlement]
        return entitlement

    async def list_by_client(self, api_client_id: UUID) -> list[ApiClientEntitlement]:
        return [row for row in self.rows if row.api_client_id == api_client_id]


class FakeClientRegistry(ClientRegistry):
    """Remembers what it was asked; only `register` and `delete` are used so far."""

    def __init__(self) -> None:
        self.registered: list[ClientSpec] = []
        self.deleted: list[str] = []
        self.delete_error: Exception | None = None

    async def register(self, spec: ClientSpec) -> RegisteredClient:
        self.registered.append(spec)
        return RegisteredClient(secret="the-secret", registration_token="rat-1")

    async def read_scopes(self, keycloak_client_id: str, registration_token: str) -> set[str]:
        raise NotImplementedError

    async def set_scopes(
        self, keycloak_client_id: str, registration_token: str, scopes: frozenset[str]
    ) -> str:
        raise NotImplementedError

    async def set_secret(
        self, keycloak_client_id: str, registration_token: str, secret: str
    ) -> str:
        raise NotImplementedError

    async def delete(self, keycloak_client_id: str, registration_token: str) -> None:
        if self.delete_error:
            raise self.delete_error
        self.deleted.append(keycloak_client_id)


class FakeProductRoleAssigner(ProductRoleAssigner):
    def __init__(self) -> None:
        self.granted: list[tuple[str, str]] = []
        self.fail = False

    async def grant(self, keycloak_client_id: str, product: str) -> None:
        if self.fail:
            raise RuntimeError("keycloak is down")
        self.granted.append((keycloak_client_id, product))

    async def revoke(self, keycloak_client_id: str, product: str) -> None:
        raise NotImplementedError


class FakeIdentityDirectory(IdentityDirectory):
    def __init__(self) -> None:
        self.users: dict[str, DirectoryUser] = {}

    async def get_user(self, subject: str) -> DirectoryUser | None:
        return self.users.get(subject)


class FakeLoginAttemptRepository(LoginAttemptRepository):
+267 −0
Changes for tests/unit/test_api_client_service.py: 267 added lines, 0 removed lines.
Original line number Diff line number Diff line
"""Creating an API client: the rules, against in-memory stand-ins for every port."""

import secrets
from uuid import UUID

import pytest

from portal_service.application.api_client_service import ApiClientService
from portal_service.application.crypto import decrypt
from portal_service.domain.api_products import ApiProductCatalog
from portal_service.domain.errors import (
    ApiClientLimitReachedError,
    ApiClientNameTakenError,
    ApiClientNotFoundError,
    CreatorNotAllowedError,
    IdentityProviderUnavailableError,
    ProductNotHeldError,
    RegistrationTokenRejectedError,
)
from portal_service.domain.models import ApiClient, DecidedBy, EntitlementState
from portal_service.domain.ports.identity_directory import DirectoryUser
from tests.fakes import (
    FakeApiClientEntitlementRepository,
    FakeApiClientRepository,
    FakeClientRegistry,
    FakeIdentityDirectory,
    FakeProductRoleAssigner,
)

KEY = secrets.token_bytes(32)
QOD_SCOPES = {"qod-sessions-read", "qod-sessions-write"}
CATALOG = ApiProductCatalog.model_validate(
    {
        "products": [
            {"name": "quality-on-demand", "scopes": sorted(QOD_SCOPES)},
            {"name": "location-retrieval", "scopes": ["location-retrieval:read"]},
        ]
    }
)


class Setup:
    def __init__(self) -> None:
        self.clients = FakeApiClientRepository()
        self.entitlements = FakeApiClientEntitlementRepository()
        self.registry = FakeClientRegistry()
        self.roles = FakeProductRoleAssigner()
        self.directory = FakeIdentityDirectory()
        self.directory.users["maria"] = DirectoryUser(
            enabled=True, organization="acme", products=frozenset({"quality-on-demand"})
        )
        self.service = ApiClientService(
            clients=self.clients,
            entitlements=self.entitlements,
            registry=self.registry,
            roles=self.roles,
            directory=self.directory,
            catalog=CATALOG,
            encryption_key=KEY,
            max_clients_per_creator=2,
        )


@pytest.fixture
def s() -> Setup:
    return Setup()


async def _create(s: Setup, name: str = "Fleet tracker", creator: str = "maria") -> ApiClient:
    new = await s.service.create(
        creator=creator, name=name, description="", products=frozenset({"quality-on-demand"})
    )
    return new.api_client


async def test_create_registers_the_client_with_the_scopes_organization_and_owner(
    s: Setup,
) -> None:
    new = await s.service.create(
        creator="maria",
        name="Fleet tracker",
        description="Nightly sync",
        products=frozenset({"quality-on-demand"}),
    )

    spec = s.registry.registered[0]
    assert spec.keycloak_client_id == new.api_client.keycloak_client_id
    assert spec.keycloak_client_id.startswith("api-")
    assert (spec.scopes, spec.organization, spec.app_provider_id) == (
        frozenset(QOD_SCOPES),
        "acme",
        "maria",
    )
    assert new.secret == "the-secret"


async def test_create_grants_the_product_role_and_records_the_entitlement(s: Setup) -> None:
    new = await s.service.create(
        creator="maria",
        name="Fleet tracker",
        description="",
        products=frozenset({"quality-on-demand"}),
    )

    assert s.roles.granted == [(new.api_client.keycloak_client_id, "quality-on-demand")]
    [entitlement] = s.entitlements.rows
    assert (entitlement.product, entitlement.state, entitlement.decided_by) == (
        "quality-on-demand",
        EntitlementState.APPROVED,
        DecidedBy.AUTO,
    )
    assert entitlement.api_client_id == new.api_client.id


async def test_the_registration_token_is_stored_encrypted(s: Setup) -> None:
    stored = (await _create(s)).registration_token

    assert b"rat-1" not in stored
    assert decrypt(stored, KEY) == b"rat-1"


async def test_a_creator_without_an_organization_owns_the_client_themselves(s: Setup) -> None:
    s.directory.users["maria"] = DirectoryUser(
        enabled=True, organization=None, products=frozenset({"quality-on-demand"})
    )

    assert (await _create(s)).application_owner_id == "maria"
    assert s.registry.registered[0].organization == "maria"


async def test_the_limit_is_per_creator(s: Setup) -> None:
    s.directory.users["ben"] = s.directory.users["maria"]
    await _create(s, "One")
    await _create(s, "Two")

    with pytest.raises(ApiClientLimitReachedError):
        await _create(s, "Three")
    await _create(s, "One", creator="ben")


async def test_a_name_is_unique_per_creator(s: Setup) -> None:
    s.directory.users["ben"] = s.directory.users["maria"]
    await _create(s)

    with pytest.raises(ApiClientNameTakenError):
        await _create(s)
    await _create(s, creator="ben")


@pytest.mark.parametrize(
    "user", [None, DirectoryUser(enabled=False, organization=None, products=frozenset())]
)
async def test_an_unknown_or_disabled_creator_is_refused(
    s: Setup, user: DirectoryUser | None
) -> None:
    del s.directory.users["maria"]
    if user is not None:
        s.directory.users["maria"] = user

    with pytest.raises(CreatorNotAllowedError):
        await _create(s)

    assert s.registry.registered == []


async def test_products_the_creator_does_not_hold_are_refused_before_anything_is_created(
    s: Setup,
) -> None:
    with pytest.raises(ProductNotHeldError, match="location-retrieval"):
        await s.service.create(
            creator="maria",
            name="Fleet tracker",
            description="",
            products=frozenset({"quality-on-demand", "location-retrieval"}),
        )

    assert (s.registry.registered, s.clients.rows) == ([], {})


async def test_a_failure_after_registering_takes_the_keycloak_client_back(s: Setup) -> None:
    s.roles.fail = True

    with pytest.raises(RuntimeError, match="keycloak is down"):
        await _create(s)

    assert s.registry.deleted == [s.registry.registered[0].keycloak_client_id]
    assert s.clients.rows == {}


async def test_a_database_failure_takes_the_keycloak_client_back_too(s: Setup) -> None:
    s.clients.fail_on_save = True

    with pytest.raises(RuntimeError, match="database is down"):
        await _create(s)

    assert s.registry.deleted == [s.registry.registered[0].keycloak_client_id]
    assert s.entitlements.rows == []


# ------------------------------------------------------------------ delete


async def _stored(s: Setup, api_client_id: UUID) -> ApiClient:
    stored = await s.clients.get_by_id(api_client_id)
    assert stored is not None
    return stored


async def test_delete_removes_the_keycloak_client_and_revokes_the_products(s: Setup) -> None:
    created = await _create(s)

    await s.service.delete(creator="maria", api_client_id=created.id)

    assert s.registry.deleted == [created.keycloak_client_id]
    [entitlement] = s.entitlements.rows
    assert (entitlement.state, entitlement.decided_by) == (
        EntitlementState.REVOKED,
        DecidedBy.CREATOR,
    )
    assert (await _stored(s, created.id)).deleted_at is not None


async def test_a_deleted_client_leaves_the_list_and_frees_its_name(s: Setup) -> None:
    created = await _create(s)

    await s.service.delete(creator="maria", api_client_id=created.id)

    assert await s.clients.list_by_creator("maria") == []
    await _create(s)


async def test_delete_refuses_unknown_foreign_and_already_deleted_clients(s: Setup) -> None:
    s.directory.users["ben"] = s.directory.users["maria"]
    created = await _create(s)
    await s.service.delete(creator="maria", api_client_id=created.id)
    other = await _create(s, name="Other")

    for creator, api_client_id in [
        ("maria", created.id),
        ("ben", other.id),
        ("maria", UUID(int=0)),
    ]:
        with pytest.raises(ApiClientNotFoundError):
            await s.service.delete(creator=creator, api_client_id=api_client_id)

    assert s.registry.deleted == [created.keycloak_client_id]


async def test_delete_still_succeeds_when_keycloak_rejects_the_token(s: Setup) -> None:
    created = await _create(s)
    s.registry.delete_error = RegistrationTokenRejectedError("disabled in the console")

    await s.service.delete(creator="maria", api_client_id=created.id)

    assert (await _stored(s, created.id)).deleted_at is not None
    assert s.entitlements.rows[0].state is EntitlementState.REVOKED


async def test_delete_changes_nothing_when_keycloak_is_unavailable(s: Setup) -> None:
    created = await _create(s)
    s.registry.delete_error = IdentityProviderUnavailableError("down")

    with pytest.raises(IdentityProviderUnavailableError):
        await s.service.delete(creator="maria", api_client_id=created.id)

    assert (await _stored(s, created.id)).deleted_at is None
    assert s.entitlements.rows[0].state is EntitlementState.APPROVED