Commit 8aebfdb2 authored by Dimitrios Gogos's avatar Dimitrios Gogos
Browse files

feat: implement cleanup mechanism for purging expired sessions and login attempts

parent 303b45aa
Loading
Loading
Loading
Loading
+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),
    )
+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))
+8 −0
Changes for src/portal_service/config.py: 8 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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"]

@@ -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")
+19 −1
Changes for src/portal_service/main.py: 19 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -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
@@ -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__)
@@ -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")

+20 −0
Changes for tests/integration/test_repositories.py: 20 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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
@@ -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