Loading src/portal_service/adapters/database/mappers.py +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: Loading Loading @@ -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, ) src/portal_service/adapters/database/repos/__init__.py +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", ] src/portal_service/adapters/database/repos/api_clients.py 0 → 100644 +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] src/portal_service/adapters/database/sql.py +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 Loading @@ -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 Loading Loading @@ -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() ) src/portal_service/domain/models.py +42 −0 Changes for src/portal_service/domain/models.py: 42 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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
src/portal_service/adapters/database/mappers.py +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: Loading Loading @@ -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, )
src/portal_service/adapters/database/repos/__init__.py +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", ]
src/portal_service/adapters/database/repos/api_clients.py 0 → 100644 +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]
src/portal_service/adapters/database/sql.py +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 Loading @@ -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 Loading Loading @@ -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() )
src/portal_service/domain/models.py +42 −0 Changes for src/portal_service/domain/models.py: 42 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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