Loading src/portal_service/application/cleanup.py 0 → 100644 +35 −0 Changes for src/portal_service/application/cleanup.py: 35 added lines, 0 removed lines. Original line number Diff line number Diff line """ Removing rows nobody will come back for. Expired sessions and abandoned login attempts are already refused wherever they are read, so this is housekeeping rather than a security control: it keeps the tables bounded and stops encrypted tokens lingering longer than the session they belonged to. """ from dataclasses import dataclass from datetime import datetime from portal_service.domain.ports.database.login_attempts import LoginAttemptRepository from portal_service.domain.ports.database.login_sessions import LoginSessionRepository @dataclass(frozen=True) class PurgeResult: sessions: int attempts: int @property def total(self) -> int: return self.sessions + self.attempts async def purge_expired( sessions: LoginSessionRepository, attempts: LoginAttemptRepository, now: datetime, ) -> PurgeResult: return PurgeResult( sessions=await sessions.delete_expired(now), attempts=await attempts.delete_expired(now), ) src/portal_service/background.py 0 → 100644 +54 −0 Changes for src/portal_service/background.py: 54 added lines, 0 removed lines. Original line number Diff line number Diff line """ Work the portal does on its own, without a request. Lives outside the application layer because it wires real repositories to a real database session, which is composition rather than logic. """ import asyncio from datetime import UTC, datetime, timedelta import structlog from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from portal_service.adapters.database.repos import ( SqlLoginAttemptRepository, SqlLoginSessionRepository, ) from portal_service.application.cleanup import PurgeResult, purge_expired logger: structlog.BoundLogger = structlog.get_logger(__name__) async def purge_once(session_maker: async_sessionmaker[AsyncSession]) -> PurgeResult: """One pass, in its own transaction.""" async with session_maker() as session: result = await purge_expired( sessions=SqlLoginSessionRepository(session), attempts=SqlLoginAttemptRepository(session), now=datetime.now(UTC), ) await session.commit() return result async def cleanup_loop( session_maker: async_sessionmaker[AsyncSession], interval: timedelta ) -> None: """ Runs until cancelled at shutdown. A failed pass is logged and the loop carries on: a database hiccup must not stop the portal from cleaning up later. """ while True: # Sleeping first keeps start-up free of database work. await asyncio.sleep(interval.total_seconds()) try: result = await purge_once(session_maker) if result.total: logger.info( "purged expired rows", sessions=result.sessions, attempts=result.attempts ) except asyncio.CancelledError: raise except Exception as exc: logger.error("cleanup pass failed", error=str(exc)) src/portal_service/config.py +8 −0 Changes for src/portal_service/config.py: 8 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -65,6 +65,13 @@ class PostgreSQLSettings(BaseModel): create_schema_on_startup: bool = False class CleanupSettings(BaseModel): """Purging rows nobody will come back for: closed browsers, abandoned logins.""" enabled: bool = True interval_seconds: int = 300 class CORSSettings(BaseModel): allowed_origins: list[str] = ["http://localhost:3000"] Loading @@ -80,6 +87,7 @@ class Settings(BaseSettings): oidc: OIDCSettings | None = None session: SessionSettings = SessionSettings() postgresql_settings: PostgreSQLSettings = PostgreSQLSettings() cleanup: CleanupSettings = CleanupSettings() cors: CORSSettings = CORSSettings() @model_validator(mode="after") Loading src/portal_service/main.py +19 −1 Changes for src/portal_service/main.py: 19 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -5,7 +5,9 @@ Bootstraps the FastAPI app: configuration, middlewares, routers, and exception handlers. No business logic lives here. """ from contextlib import asynccontextmanager import asyncio from contextlib import asynccontextmanager, suppress from datetime import timedelta from typing import AsyncIterator import structlog Loading @@ -20,6 +22,7 @@ from portal_service.api.auth.routes import auth_router from portal_service.api.exception_handlers import register_exception_handlers from portal_service.api.health import health_router from portal_service.api.middlewares import register_middlewares from portal_service.background import cleanup_loop from portal_service.config import get_settings logger: structlog.BoundLogger = structlog.get_logger(__name__) Loading Loading @@ -49,8 +52,23 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: app.state.db_engine = db_engine app.state.session_maker = session_maker cleanup: asyncio.Task[None] | None = None if settings.cleanup.enabled: cleanup = asyncio.create_task( cleanup_loop(session_maker, timedelta(seconds=settings.cleanup.interval_seconds)) ) logger.info("cleanup loop started", interval_seconds=settings.cleanup.interval_seconds) yield if cleanup is not None: cleanup.cancel() # Waiting for the cancellation keeps shutdown quiet: an un-awaited cancelled # task complains on the way out. with suppress(asyncio.CancelledError): await cleanup logger.info("cleanup loop stopped") await db_engine.dispose() logger.info("portal backend shutting down") Loading tests/integration/test_repositories.py +20 −0 Changes for tests/integration/test_repositories.py: 20 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -15,6 +15,7 @@ from portal_service.adapters.database.repos import ( SqlLoginAttemptRepository, SqlLoginSessionRepository, ) from portal_service.application.cleanup import purge_expired from portal_service.domain.models import LoginAttempt, LoginSession pytestmark = pytest.mark.integration Loading Loading @@ -126,3 +127,22 @@ async def test_abandoned_attempts_are_purged(db_session: AsyncSession) -> None: assert deleted == 1 assert await repo.take("c" * 64) is not None # ------------------------------------------------------------------------- cleanup async def test_purge_clears_both_tables_in_one_pass(db_session: AsyncSession) -> None: sessions = SqlLoginSessionRepository(db_session) attempts = SqlLoginAttemptRepository(db_session) await sessions.save(_session_row("a" * 64, expires_in=timedelta(hours=8), tokens=b"live")) await sessions.save(_session_row("b" * 64, expires_in=timedelta(seconds=-1), tokens=b"dead")) await attempts.save(_attempt("c" * 64, expires_in=timedelta(minutes=5))) await attempts.save(_attempt("d" * 64, expires_in=timedelta(seconds=-1))) result = await purge_expired(sessions, attempts, datetime.now(UTC)) assert (result.sessions, result.attempts) == (1, 1) assert await sessions.get_by_id("a" * 64) is not None assert await sessions.get_by_id("b" * 64) is None assert await attempts.take("c" * 64) is not None Loading
src/portal_service/application/cleanup.py 0 → 100644 +35 −0 Changes for src/portal_service/application/cleanup.py: 35 added lines, 0 removed lines. Original line number Diff line number Diff line """ Removing rows nobody will come back for. Expired sessions and abandoned login attempts are already refused wherever they are read, so this is housekeeping rather than a security control: it keeps the tables bounded and stops encrypted tokens lingering longer than the session they belonged to. """ from dataclasses import dataclass from datetime import datetime from portal_service.domain.ports.database.login_attempts import LoginAttemptRepository from portal_service.domain.ports.database.login_sessions import LoginSessionRepository @dataclass(frozen=True) class PurgeResult: sessions: int attempts: int @property def total(self) -> int: return self.sessions + self.attempts async def purge_expired( sessions: LoginSessionRepository, attempts: LoginAttemptRepository, now: datetime, ) -> PurgeResult: return PurgeResult( sessions=await sessions.delete_expired(now), attempts=await attempts.delete_expired(now), )
src/portal_service/background.py 0 → 100644 +54 −0 Changes for src/portal_service/background.py: 54 added lines, 0 removed lines. Original line number Diff line number Diff line """ Work the portal does on its own, without a request. Lives outside the application layer because it wires real repositories to a real database session, which is composition rather than logic. """ import asyncio from datetime import UTC, datetime, timedelta import structlog from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from portal_service.adapters.database.repos import ( SqlLoginAttemptRepository, SqlLoginSessionRepository, ) from portal_service.application.cleanup import PurgeResult, purge_expired logger: structlog.BoundLogger = structlog.get_logger(__name__) async def purge_once(session_maker: async_sessionmaker[AsyncSession]) -> PurgeResult: """One pass, in its own transaction.""" async with session_maker() as session: result = await purge_expired( sessions=SqlLoginSessionRepository(session), attempts=SqlLoginAttemptRepository(session), now=datetime.now(UTC), ) await session.commit() return result async def cleanup_loop( session_maker: async_sessionmaker[AsyncSession], interval: timedelta ) -> None: """ Runs until cancelled at shutdown. A failed pass is logged and the loop carries on: a database hiccup must not stop the portal from cleaning up later. """ while True: # Sleeping first keeps start-up free of database work. await asyncio.sleep(interval.total_seconds()) try: result = await purge_once(session_maker) if result.total: logger.info( "purged expired rows", sessions=result.sessions, attempts=result.attempts ) except asyncio.CancelledError: raise except Exception as exc: logger.error("cleanup pass failed", error=str(exc))
src/portal_service/config.py +8 −0 Changes for src/portal_service/config.py: 8 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -65,6 +65,13 @@ class PostgreSQLSettings(BaseModel): create_schema_on_startup: bool = False class CleanupSettings(BaseModel): """Purging rows nobody will come back for: closed browsers, abandoned logins.""" enabled: bool = True interval_seconds: int = 300 class CORSSettings(BaseModel): allowed_origins: list[str] = ["http://localhost:3000"] Loading @@ -80,6 +87,7 @@ class Settings(BaseSettings): oidc: OIDCSettings | None = None session: SessionSettings = SessionSettings() postgresql_settings: PostgreSQLSettings = PostgreSQLSettings() cleanup: CleanupSettings = CleanupSettings() cors: CORSSettings = CORSSettings() @model_validator(mode="after") Loading
src/portal_service/main.py +19 −1 Changes for src/portal_service/main.py: 19 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -5,7 +5,9 @@ Bootstraps the FastAPI app: configuration, middlewares, routers, and exception handlers. No business logic lives here. """ from contextlib import asynccontextmanager import asyncio from contextlib import asynccontextmanager, suppress from datetime import timedelta from typing import AsyncIterator import structlog Loading @@ -20,6 +22,7 @@ from portal_service.api.auth.routes import auth_router from portal_service.api.exception_handlers import register_exception_handlers from portal_service.api.health import health_router from portal_service.api.middlewares import register_middlewares from portal_service.background import cleanup_loop from portal_service.config import get_settings logger: structlog.BoundLogger = structlog.get_logger(__name__) Loading Loading @@ -49,8 +52,23 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: app.state.db_engine = db_engine app.state.session_maker = session_maker cleanup: asyncio.Task[None] | None = None if settings.cleanup.enabled: cleanup = asyncio.create_task( cleanup_loop(session_maker, timedelta(seconds=settings.cleanup.interval_seconds)) ) logger.info("cleanup loop started", interval_seconds=settings.cleanup.interval_seconds) yield if cleanup is not None: cleanup.cancel() # Waiting for the cancellation keeps shutdown quiet: an un-awaited cancelled # task complains on the way out. with suppress(asyncio.CancelledError): await cleanup logger.info("cleanup loop stopped") await db_engine.dispose() logger.info("portal backend shutting down") Loading
tests/integration/test_repositories.py +20 −0 Changes for tests/integration/test_repositories.py: 20 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -15,6 +15,7 @@ from portal_service.adapters.database.repos import ( SqlLoginAttemptRepository, SqlLoginSessionRepository, ) from portal_service.application.cleanup import purge_expired from portal_service.domain.models import LoginAttempt, LoginSession pytestmark = pytest.mark.integration Loading Loading @@ -126,3 +127,22 @@ async def test_abandoned_attempts_are_purged(db_session: AsyncSession) -> None: assert deleted == 1 assert await repo.take("c" * 64) is not None # ------------------------------------------------------------------------- cleanup async def test_purge_clears_both_tables_in_one_pass(db_session: AsyncSession) -> None: sessions = SqlLoginSessionRepository(db_session) attempts = SqlLoginAttemptRepository(db_session) await sessions.save(_session_row("a" * 64, expires_in=timedelta(hours=8), tokens=b"live")) await sessions.save(_session_row("b" * 64, expires_in=timedelta(seconds=-1), tokens=b"dead")) await attempts.save(_attempt("c" * 64, expires_in=timedelta(minutes=5))) await attempts.save(_attempt("d" * 64, expires_in=timedelta(seconds=-1))) result = await purge_expired(sessions, attempts, datetime.now(UTC)) assert (result.sessions, result.attempts) == (1, 1) assert await sessions.get_by_id("a" * 64) is not None assert await sessions.get_by_id("b" * 64) is None assert await attempts.take("c" * 64) is not None