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

feat: implement ApiClient domain model, repository, and mapper with database integration

parent 631fd1b1
Loading
Loading
Loading
Loading
+50 −2
Changes for src/portal_service/adapters/database/mappers.py: 50 added lines, 2 removed lines.
Original line number Diff line number Diff line
from portal_service.adapters.database.sql import LoginAttemptRow, LoginSessionRow
from portal_service.domain.models import LoginAttempt, LoginSession
from portal_service.adapters.database.sql import ApiClientRow, LoginAttemptRow, LoginSessionRow
from portal_service.domain.models import (
    ApiClient,
    ApprovalStatus,
    DisabledBy,
    LoginAttempt,
    LoginSession,
    OperationalState,
)


class LoginSessionMapper:
@@ -50,3 +57,44 @@ class LoginAttemptMapper:
            return_to=domain.return_to,
            expires_at=domain.expires_at,
        )


class ApiClientMapper:
    @staticmethod
    def to_domain(row: ApiClientRow) -> ApiClient:
        return ApiClient(
            id=row.id,
            keycloak_client_id=row.keycloak_client_id,
            name=row.name,
            description=row.description,
            created_by=row.created_by,
            application_owner_id=row.application_owner_id,
            approval_status=ApprovalStatus(row.approval_status),
            operational_state=OperationalState(row.operational_state),
            disabled_by=DisabledBy(row.disabled_by) if row.disabled_by else None,
            disabled_reason=row.disabled_reason,
            registration_token=row.registration_token,
            secret_rotated_at=row.secret_rotated_at,
            deleted_at=row.deleted_at,
            created_at=row.created_at,
            updated_at=row.updated_at,
        )

    @staticmethod
    def to_row(domain: ApiClient) -> ApiClientRow:
        # created_at and updated_at are left out: the database sets them.
        return ApiClientRow(
            id=domain.id,
            keycloak_client_id=domain.keycloak_client_id,
            name=domain.name,
            description=domain.description,
            created_by=domain.created_by,
            application_owner_id=domain.application_owner_id,
            approval_status=domain.approval_status,
            operational_state=domain.operational_state,
            disabled_by=domain.disabled_by,
            disabled_reason=domain.disabled_reason,
            registration_token=domain.registration_token,
            secret_rotated_at=domain.secret_rotated_at,
            deleted_at=domain.deleted_at,
        )
+2 −0
Changes for src/portal_service/adapters/database/repos/__init__.py: 2 added lines, 0 removed lines.
Original line number Diff line number Diff line
from portal_service.adapters.database.repos.api_clients import SqlApiClientRepository
from portal_service.adapters.database.repos.login_attempts import SqlLoginAttemptRepository
from portal_service.adapters.database.repos.login_sessions import SqlLoginSessionRepository

__all__ = [
    "SqlApiClientRepository",
    "SqlLoginAttemptRepository",
    "SqlLoginSessionRepository",
]
+33 −0
Changes for src/portal_service/adapters/database/repos/api_clients.py: 33 added lines, 0 removed lines.
Original line number Diff line number Diff line
from uuid import UUID

from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from portal_service.adapters.database.mappers import ApiClientMapper
from portal_service.adapters.database.sql import ApiClientRow
from portal_service.domain.models import ApiClient
from portal_service.domain.ports.database.api_clients import ApiClientRepository


class SqlApiClientRepository(ApiClientRepository):
    def __init__(self, session: AsyncSession) -> None:
        self._session = session

    async def save(self, api_client: ApiClient) -> ApiClient:
        merged = await self._session.merge(ApiClientMapper.to_row(api_client))
        await self._session.flush()
        await self._session.refresh(merged)
        return ApiClientMapper.to_domain(merged)

    async def get_by_id(self, api_client_id: UUID) -> ApiClient | None:
        row = await self._session.get(ApiClientRow, api_client_id)
        return ApiClientMapper.to_domain(row) if row is not None else None

    async def list_by_creator(self, created_by: str) -> list[ApiClient]:
        stmt = (
            select(ApiClientRow)
            .where(ApiClientRow.created_by == created_by, ApiClientRow.deleted_at.is_(None))
            .order_by(ApiClientRow.created_at)
        )
        rows = await self._session.scalars(stmt)
        return [ApiClientMapper.to_domain(row) for row in rows]
+40 −1
Changes for src/portal_service/adapters/database/sql.py: 40 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -7,8 +7,9 @@ already exists.
"""

from datetime import datetime
from uuid import UUID

from sqlalchemy import DateTime, Index, LargeBinary, MetaData, String, func
from sqlalchemy import DateTime, Index, LargeBinary, MetaData, String, func, text
from sqlalchemy.ext.asyncio import AsyncAttrs
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

@@ -68,3 +69,41 @@ class LoginAttemptRow(Base):
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
    # A few minutes: long enough to type a password, short enough to limit replay.
    expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))


class ApiClientRow(Base):
    """
    An application registered to call OEG with client credentials. Keycloak holds the
    client and its secret; this row records who owns it and what state it is in.
    """

    __tablename__ = "api_clients"
    __table_args__ = (
        Index("idx_api_clients_created_by", "created_by"),
        Index(
            "uq_api_clients_creator_name",
            "created_by",
            "name",
            unique=True,
            postgresql_where=text("deleted_at IS NULL"),
        ),
    )

    id: Mapped[UUID] = mapped_column(primary_key=True)
    keycloak_client_id: Mapped[str] = mapped_column(String(255), unique=True)
    name: Mapped[str] = mapped_column(String(100))
    description: Mapped[str] = mapped_column(String(500))
    created_by: Mapped[str] = mapped_column(String(255))
    application_owner_id: Mapped[str] = mapped_column(String(255))
    approval_status: Mapped[str] = mapped_column(String(32))
    operational_state: Mapped[str] = mapped_column(String(32))
    disabled_by: Mapped[str | None] = mapped_column(String(32))
    disabled_reason: Mapped[str | None] = mapped_column(String(255))
    # Keycloak's registration access token, AES-GCM encrypted.
    registration_token: Mapped[bytes] = mapped_column(LargeBinary)
    secret_rotated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
    deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
    updated_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
    )
+42 −0
Changes for src/portal_service/domain/models.py: 42 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -5,6 +5,7 @@ Core domain models — pure Python, no framework dependencies.
from datetime import datetime
from enum import StrEnum
from typing import Any
from uuid import UUID

from pydantic import BaseModel, Field

@@ -104,3 +105,44 @@ class LoginAttempt(BaseModel):
    return_to: str
    expires_at: datetime
    created_at: datetime | None = None


class ApprovalStatus(StrEnum):
    PENDING_APPROVAL = "pending_approval"
    APPROVED = "approved"
    REJECTED = "rejected"


class OperationalState(StrEnum):
    ENABLED = "enabled"
    DISABLED = "disabled"


class DisabledBy(StrEnum):
    CREATOR = "creator"
    OPERATOR = "operator"
    SYSTEM = "system"


class ApiClient(BaseModel):
    """
    An application a developer registered to call OEG with client credentials. Keycloak
    holds its secret; `registration_token` is the encrypted key for managing it there.
    """

    id: UUID
    keycloak_client_id: str
    name: str
    description: str = ""
    created_by: str
    # The organization, or the creator's `sub` when they have none.
    application_owner_id: str
    approval_status: ApprovalStatus = ApprovalStatus.APPROVED
    operational_state: OperationalState = OperationalState.ENABLED
    disabled_by: DisabledBy | None = None
    disabled_reason: str | None = None
    registration_token: bytes = Field(repr=False)
    secret_rotated_at: datetime | None = None
    deleted_at: datetime | None = None
    created_at: datetime | None = None
    updated_at: datetime | None = None
Loading