Commit 086e0c3c authored by Dimitrios Gogos's avatar Dimitrios Gogos
Browse files

feat: implement Keycloak product roles management for service accounts with unit tests

parent 38e70fde
Loading
Loading
Loading
Loading
+1 −1
Changes for src/portal_service/adapters/keycloak/_http.py: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -29,7 +29,7 @@ async def send(
    timeout: float,
    transport: httpx.AsyncBaseTransport | None,
    data: dict[str, str] | None = None,
    json: dict[str, Any] | None = None,
    json: dict[str, Any] | list[dict[str, Any]] | None = None,
    headers: dict[str, str] | None = None,
) -> httpx.Response:
    """Any status comes back as a response; only a network failure raises."""
+87 −0
Changes for src/portal_service/adapters/keycloak/product_roles.py: 87 added lines, 0 removed lines.
Original line number Diff line number Diff line
"""
Product roles on API clients' service accounts, through Keycloak's Admin API.

The provisioner may map only the product roles, and read only the `oeg` client (to get the
roles' ids), so the `oeg` client's id has to be known: it cannot be searched for.
"""

from collections.abc import Awaitable, Callable
from typing import Any

import httpx
from pydantic import BaseModel, RootModel

from portal_service.adapters.keycloak import _http
from portal_service.domain.errors import ServiceAccountNotFoundError
from portal_service.domain.ports.product_roles import ProductRoleAssigner

_WHAT = "Keycloak admin API"


class _User(BaseModel):
    id: str


class _Role(BaseModel):
    id: str
    name: str


class KeycloakProductRoles(ProductRoleAssigner):
    def __init__(
        self,
        *,
        admin_url: str,
        oeg_client_uuid: str,
        provisioner_token: Callable[[], Awaitable[str]],
        timeout: float = 10.0,
        transport: httpx.AsyncBaseTransport | None = None,
    ) -> None:
        self._admin_url = admin_url
        self._oeg_client_uuid = oeg_client_uuid
        self._provisioner_token = provisioner_token
        self._timeout = timeout
        self._transport = transport

    async def grant(self, keycloak_client_id: str, product: str) -> None:
        await self._map("POST", keycloak_client_id, product)

    async def revoke(self, keycloak_client_id: str, product: str) -> None:
        await self._map("DELETE", keycloak_client_id, product)

    async def _map(self, method: str, keycloak_client_id: str, product: str) -> None:
        user_id = await self._service_account_id(keycloak_client_id)
        role = await self._product_role(product)
        mappings = f"/users/{user_id}/role-mappings/clients/{self._oeg_client_uuid}"
        await self._call(method, mappings, json=[role.model_dump()])

    async def _service_account_id(self, keycloak_client_id: str) -> str:
        username = f"service-account-{keycloak_client_id}"
        response = await self._call("GET", f"/users?username={username}&exact=true")
        users = _http.parse(RootModel[list[_User]], response, what=_WHAT).root
        if not users:
            raise ServiceAccountNotFoundError(f"No service account for {keycloak_client_id}")
        return users[0].id

    async def _product_role(self, product: str) -> _Role:
        response = await self._call("GET", f"/clients/{self._oeg_client_uuid}/roles")
        roles = _http.parse(RootModel[list[_Role]], response, what=_WHAT).root
        for role in roles:
            if role.name == product:
                return role
        raise ValueError(f"No product role named {product!r} on the oeg client")

    async def _call(
        self, method: str, path: str, json: list[dict[str, Any]] | None = None
    ) -> httpx.Response:
        response = await _http.send(
            method,
            f"{self._admin_url}{path}",
            what=_WHAT,
            timeout=self._timeout,
            transport=self._transport,
            json=json,
            headers={"Authorization": f"Bearer {await self._provisioner_token()}"},
        )
        _http.ensure_ok(response, what=_WHAT)
        return response
+5 −0
Changes for src/portal_service/domain/errors.py: 5 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -44,3 +44,8 @@ class IdentityProviderUnavailableError(Exception):
# the console, or its token was replaced. Keycloak does not say which.
class RegistrationTokenRejectedError(Exception):
    pass


# The client has no service-account user: it was never registered, or it was deleted.
class ServiceAccountNotFoundError(Exception):
    pass
+18 −0
Changes for src/portal_service/domain/ports/product_roles.py: 18 added lines, 0 removed lines.
Original line number Diff line number Diff line
"""Product role port: giving an API client's service account the roles of its API products."""

from abc import ABC, abstractmethod


class ProductRoleAssigner(ABC):
    """
    Keycloak puts a product's scopes in a token only if its holder has the product's role,
    so an API client needs the role as well as the scopes.
    """

    @abstractmethod
    async def grant(self, keycloak_client_id: str, product: str) -> None:
        pass

    @abstractmethod
    async def revoke(self, keycloak_client_id: str, product: str) -> None:
        pass
+116 −0
Changes for tests/unit/test_product_roles.py: 116 added lines, 0 removed lines.
Original line number Diff line number Diff line
"""Product roles on service accounts, against a fake Keycloak admin API."""

import json
from urllib.parse import parse_qs

import httpx
import pytest

from portal_service.adapters.keycloak.product_roles import KeycloakProductRoles
from portal_service.domain.errors import (
    IdentityProviderUnavailableError,
    ServiceAccountNotFoundError,
)

ADMIN = "http://keycloak.test/admin/realms/oop"
OEG = "oeg-uuid"
ROLES = [
    {"id": "role-qod", "name": "quality-on-demand"},
    {"id": "role-eam", "name": "edge-application-management"},
]


class FakeAdminApi:
    """One app with a service account, and the product roles mapped to it."""

    def __init__(self) -> None:
        self.requests: list[httpx.Request] = []
        self.mapped: set[str] = set()

    def __call__(self, request: httpx.Request) -> httpx.Response:
        self.requests.append(request)
        if request.headers["Authorization"] != "Bearer provisioner-token":
            return httpx.Response(401)
        path = request.url.path.removeprefix("/admin/realms/oop")
        if path == "/users":
            wanted = parse_qs(request.url.query.decode())["username"][0]
            found = (
                [{"id": "sa-id", "username": wanted}] if wanted == "service-account-api-1" else []
            )
            return httpx.Response(200, json=found)
        if path == f"/clients/{OEG}/roles":
            return httpx.Response(200, json=ROLES)
        if path == f"/users/sa-id/role-mappings/clients/{OEG}":
            names = {role["name"] for role in json.loads(request.content)}
            self.mapped = self.mapped | names if request.method == "POST" else self.mapped - names
            return httpx.Response(204)
        return httpx.Response(404)


async def _token() -> str:
    return "provisioner-token"


@pytest.fixture
def admin() -> FakeAdminApi:
    return FakeAdminApi()


@pytest.fixture
def roles(admin: FakeAdminApi) -> KeycloakProductRoles:
    return KeycloakProductRoles(
        admin_url=ADMIN,
        oeg_client_uuid=OEG,
        provisioner_token=_token,
        transport=httpx.MockTransport(admin),
    )


async def test_grant_maps_the_product_role_to_the_service_account(
    roles: KeycloakProductRoles, admin: FakeAdminApi
) -> None:
    await roles.grant("api-1", "quality-on-demand")

    assert admin.mapped == {"quality-on-demand"}
    sent = json.loads(admin.requests[-1].content)
    assert sent == [{"id": "role-qod", "name": "quality-on-demand"}]
    assert "username=service-account-api-1&exact=true" in str(admin.requests[0].url)


async def test_revoke_removes_only_that_product_role(
    roles: KeycloakProductRoles, admin: FakeAdminApi
) -> None:
    await roles.grant("api-1", "quality-on-demand")
    await roles.grant("api-1", "edge-application-management")

    await roles.revoke("api-1", "quality-on-demand")

    assert admin.mapped == {"edge-application-management"}


async def test_an_unknown_product_is_refused_before_anything_is_mapped(
    roles: KeycloakProductRoles, admin: FakeAdminApi
) -> None:
    with pytest.raises(ValueError, match="number-verification"):
        await roles.grant("api-1", "number-verification")

    assert admin.mapped == set()


async def test_a_client_without_a_service_account_is_reported(
    roles: KeycloakProductRoles,
) -> None:
    with pytest.raises(ServiceAccountNotFoundError, match="api-gone"):
        await roles.grant("api-gone", "quality-on-demand")


async def test_a_refused_request_is_unavailable() -> None:
    forbidden = KeycloakProductRoles(
        admin_url=ADMIN,
        oeg_client_uuid=OEG,
        provisioner_token=_token,
        transport=httpx.MockTransport(lambda request: httpx.Response(403)),
    )

    with pytest.raises(IdentityProviderUnavailableError):
        await forbidden.grant("api-1", "quality-on-demand")