- Remove structlog dependency from backend/pyproject.toml - Add app.utils.logging_compat shim for keyword-arg logging API - Add app.utils.json_formatter for JSON log output with extra fields - Update all backend modules to use logging_compat.get_logger() - Update docstrings in log_sanitizer.py and json_formatter.py - Update test comment in test_async_utils.py - Record 406 failing tests in Docs/Tasks.md for tracking
735 lines
27 KiB
Python
735 lines
27 KiB
Python
"""FastAPI dependency providers and composition root.
|
|
|
|
This module is BanGUI's dependency injection composition root. All injectable
|
|
resources — database connections, settings, services, repositories, and
|
|
authentication guards — are defined here as provider functions.
|
|
|
|
**Key Principles:**
|
|
|
|
1. **Composition Root Pattern**: No heavyweight DI container is used. Instead,
|
|
FastAPI's `Depends()` framework manages all dependencies, keeping the pattern
|
|
lightweight and explicit.
|
|
|
|
2. **Explicit Over Implicit**: Every dependency is declared in function signatures.
|
|
There is no hidden coupling or magic. This makes the dependency graph visible
|
|
to type checkers, debuggers, and developers.
|
|
|
|
3. **Service Context Dependencies**: Related resources (e.g., db + repository) are
|
|
bundled into context objects (SessionServiceContext, BlocklistServiceContext)
|
|
to prevent routers from accessing raw database connections.
|
|
|
|
4. **Repository Boundary Enforcement**: Routers must NOT import DbDep. They depend
|
|
on service context dependencies instead, which contain both the database
|
|
connection and the necessary repositories. This ensures repositories are the
|
|
only modules executing SQL.
|
|
|
|
See Architekture.md § 2.3 (Dependency Wiring and Service Composition) for a
|
|
complete guide to the DI pattern, including examples of adding new services.
|
|
|
|
See Backend-Development.md § 6 for dependency layering rules.
|
|
"""
|
|
|
|
import datetime
|
|
from collections.abc import AsyncGenerator, Awaitable, Callable
|
|
from dataclasses import dataclass
|
|
from typing import Annotated, cast
|
|
|
|
import aiohttp
|
|
import aiosqlite
|
|
from app.utils.logging_compat import get_logger
|
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler # type: ignore[import-untyped]
|
|
from fastapi import Depends, FastAPI, HTTPException, Request, status
|
|
|
|
from app.config import Settings
|
|
from app.exceptions import RateLimitError
|
|
from app.models.auth import Session
|
|
from app.models.config import PendingRecovery
|
|
from app.models.server import ServerStatus
|
|
from app.repositories.protocols import (
|
|
BlocklistRepository,
|
|
Fail2BanDbRepository,
|
|
GeoCacheRepository,
|
|
HistoryArchiveRepository,
|
|
ImportLogRepository,
|
|
ImportRunRepository,
|
|
SessionRepository,
|
|
SettingsRepository,
|
|
)
|
|
from app.services.geo_cache import GeoCache
|
|
from app.services.protocols import Fail2BanMetadataService
|
|
from app.utils.constants import SESSION_COOKIE_NAME
|
|
from app.utils.rate_limiter import GlobalRateLimiter
|
|
from app.utils.runtime_state import ApplicationState, JailServiceState, RuntimeState
|
|
from app.utils.session_cache import NoOpSessionCache, SessionCache
|
|
|
|
# Module-level imports for repositories and services
|
|
# These are safe at module level since no circular dependencies exist
|
|
from app.repositories import (
|
|
blocklist_repo,
|
|
fail2ban_db_repo,
|
|
geo_cache_repo,
|
|
history_archive_repo,
|
|
import_log_repo,
|
|
import_run_repo,
|
|
session_repo,
|
|
settings_repo,
|
|
)
|
|
from app.services import auth_service, health_service
|
|
from app.services.fail2ban_metadata_service import default_fail2ban_metadata_service
|
|
|
|
log = get_logger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class ApplicationContext:
|
|
"""A typed wrapper around shared application lifecycle resources."""
|
|
|
|
settings: Settings
|
|
http_session: aiohttp.ClientSession | None
|
|
scheduler: AsyncIOScheduler | None
|
|
server_status: ServerStatus
|
|
pending_recovery: PendingRecovery | None
|
|
last_activation: dict[str, datetime.datetime] | None
|
|
runtime_settings: Settings | None
|
|
runtime_state: RuntimeState
|
|
session_cache: SessionCache | None
|
|
global_rate_limiter: GlobalRateLimiter
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Session validation cache
|
|
# ---------------------------------------------------------------------------
|
|
|
|
#: How long (seconds) a validated session token is served from the in-memory
|
|
#: cache without re-querying SQLite. Eliminates repeated DB lookups for the
|
|
#: same token arriving in near-simultaneous parallel requests.
|
|
#:
|
|
#: NOTE: this cache is process-local and is not cluster-safe. In multi-worker
|
|
#: or distributed deployments, the configured cache backend should provide
|
|
#: invalidation semantics appropriate for the deployment.
|
|
|
|
def _session_cache_enabled(settings: Settings) -> bool:
|
|
"""Return whether the session validation cache should be used."""
|
|
return settings.session_cache_enabled and settings.session_cache_ttl_seconds > 0.0
|
|
|
|
|
|
def _build_app_context(request: Request) -> ApplicationContext:
|
|
state = cast("ApplicationState", request.app.state)
|
|
session_cache = getattr(state, "session_cache", None)
|
|
if session_cache is None:
|
|
session_cache = NoOpSessionCache()
|
|
|
|
global_rate_limiter: GlobalRateLimiter = getattr(state, "global_rate_limiter", None)
|
|
if global_rate_limiter is None:
|
|
global_rate_limiter = GlobalRateLimiter()
|
|
|
|
return ApplicationContext(
|
|
settings=state.settings,
|
|
http_session=getattr(state, "http_session", None),
|
|
scheduler=getattr(state, "scheduler", None),
|
|
server_status=getattr(state, "server_status", ServerStatus(online=False)),
|
|
pending_recovery=getattr(state, "pending_recovery", None),
|
|
last_activation=getattr(state, "last_activation", None),
|
|
runtime_settings=getattr(state, "runtime_settings", None),
|
|
runtime_state=state.runtime_state,
|
|
session_cache=session_cache,
|
|
global_rate_limiter=global_rate_limiter,
|
|
)
|
|
|
|
|
|
async def get_app_context(request: Request) -> ApplicationContext:
|
|
"""Provide the typed application context for the current request."""
|
|
return _build_app_context(request)
|
|
|
|
|
|
async def get_settings(app_context: Annotated[ApplicationContext, Depends(get_app_context)]) -> Settings:
|
|
"""Provide the effective application settings for the current request."""
|
|
return app_context.runtime_settings if app_context.runtime_settings is not None else app_context.settings
|
|
|
|
|
|
async def get_db(
|
|
settings: Annotated[Settings, Depends(get_settings)],
|
|
) -> AsyncGenerator[aiosqlite.Connection, None]:
|
|
"""Provide a request-scoped :class:`aiosqlite.Connection` for the current request.
|
|
|
|
Opens a fresh connection for every request and closes it when the request
|
|
is finished. This avoids contention and locking issues from a single shared
|
|
SQLite connection across concurrent requests.
|
|
|
|
The database path is taken from the effective application settings so
|
|
runtime overrides stored during setup are respected.
|
|
|
|
Args:
|
|
settings: The effective application settings for the current request.
|
|
|
|
Yields:
|
|
An open :class:`aiosqlite.Connection` for the request.
|
|
"""
|
|
from app.db import open_db # noqa: PLC0415
|
|
|
|
try:
|
|
db = await open_db(settings.database_path)
|
|
except Exception as exc:
|
|
log.error("database_open_failed", error=str(exc))
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="Database is not available.",
|
|
) from exc
|
|
|
|
try:
|
|
yield db
|
|
finally:
|
|
await db.close()
|
|
|
|
|
|
async def get_http_session(
|
|
app_context: Annotated[ApplicationContext, Depends(get_app_context)],
|
|
) -> aiohttp.ClientSession:
|
|
"""Provide the shared HTTP client session from application context.
|
|
|
|
Args:
|
|
app_context: The injected shared application context.
|
|
|
|
Returns:
|
|
A shared :class:`aiohttp.ClientSession` managed by the lifespan.
|
|
|
|
Raises:
|
|
HTTPException: If the session is unavailable.
|
|
"""
|
|
if app_context.http_session is None:
|
|
log.error("http_session_unavailable")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="HTTP session is not available.",
|
|
)
|
|
return app_context.http_session
|
|
|
|
|
|
async def get_scheduler(app_context: Annotated[ApplicationContext, Depends(get_app_context)]) -> AsyncIOScheduler:
|
|
"""Provide the shared scheduler from application context.
|
|
|
|
Args:
|
|
app_context: The injected shared application context.
|
|
|
|
Returns:
|
|
The :class:`apscheduler.schedulers.asyncio.AsyncIOScheduler` instance.
|
|
|
|
Raises:
|
|
HTTPException: If the scheduler is unavailable.
|
|
"""
|
|
if app_context.scheduler is None:
|
|
log.error("scheduler_unavailable")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="Scheduler is not available.",
|
|
)
|
|
return app_context.scheduler
|
|
|
|
|
|
async def get_fail2ban_socket(settings: Settings = Depends(get_settings)) -> str:
|
|
"""Provide the configured path to the fail2ban Unix domain socket."""
|
|
return settings.fail2ban_socket
|
|
|
|
|
|
async def get_fail2ban_config_dir(settings: Settings = Depends(get_settings)) -> str:
|
|
"""Provide the configured fail2ban configuration directory."""
|
|
return settings.fail2ban_config_dir
|
|
|
|
|
|
async def get_fail2ban_start_command(settings: Settings = Depends(get_settings)) -> str:
|
|
"""Provide the configured fail2ban start command."""
|
|
return settings.fail2ban_start_command
|
|
|
|
|
|
async def get_geo_cache(request: Request) -> GeoCache:
|
|
"""Provide the application's GeoCache instance."""
|
|
geo_cache: GeoCache = cast("GeoCache", request.app.state.geo_cache)
|
|
return geo_cache
|
|
|
|
|
|
async def get_session_cache(app_context: Annotated[ApplicationContext, Depends(get_app_context)]) -> SessionCache:
|
|
"""Provide the configured session cache backend from application context."""
|
|
if app_context.session_cache is None:
|
|
log.error("session_cache_unavailable")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="Session cache is not available.",
|
|
)
|
|
return app_context.session_cache
|
|
|
|
|
|
async def get_global_rate_limiter(
|
|
app_context: Annotated[ApplicationContext, Depends(get_app_context)],
|
|
) -> GlobalRateLimiter:
|
|
"""Provide the global rate limiter from application context."""
|
|
return app_context.global_rate_limiter
|
|
|
|
|
|
def rate_limit_dependency(
|
|
bucket: str,
|
|
max_requests: int,
|
|
window_seconds: int,
|
|
) -> Callable[[Request, "GlobalRateLimiter"], None]:
|
|
"""Create a rate limit dependency for a specific bucket and limit.
|
|
|
|
Use this factory to create per-endpoint rate limit dependencies.
|
|
Each call returns a configured dependency that enforces the
|
|
specified rate limit before the endpoint handler runs.
|
|
|
|
Args:
|
|
bucket: Bucket name (e.g., "bans:ban", "blocklist:import").
|
|
max_requests: Maximum requests allowed within the window.
|
|
window_seconds: Time window for this bucket.
|
|
|
|
Returns:
|
|
A callable that can be used as a FastAPI Depends() dependency.
|
|
"""
|
|
async def check_rate_limit(
|
|
request: Request,
|
|
rate_limiter: GlobalRateLimiterDep,
|
|
) -> None:
|
|
from app.utils.client_ip import get_client_ip
|
|
|
|
settings: Settings = request.app.state.settings
|
|
client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies)
|
|
|
|
is_allowed, retry_after = rate_limiter.check_allowed_for_bucket(
|
|
bucket, client_ip, max_requests, window_seconds
|
|
)
|
|
|
|
if not is_allowed:
|
|
log.warning(
|
|
"operation_rate_limit_exceeded",
|
|
client_ip=client_ip,
|
|
bucket=bucket,
|
|
path=request.url.path,
|
|
method=request.method,
|
|
retry_after=retry_after,
|
|
)
|
|
raise RateLimitError(
|
|
f"Rate limit exceeded for {bucket}. Please try again later.",
|
|
retry_after_seconds=retry_after,
|
|
)
|
|
|
|
return check_rate_limit
|
|
|
|
|
|
async def get_session_repo() -> SessionRepository:
|
|
"""Provide the concrete session repository implementation.
|
|
|
|
The session_repo module uses structural typing to satisfy the SessionRepository
|
|
Protocol interface — its top-level async functions must match the Protocol
|
|
signatures exactly. This is documented in Backend-Development.md § 13.7.1.
|
|
"""
|
|
return session_repo
|
|
|
|
|
|
async def get_blocklist_repo() -> BlocklistRepository:
|
|
"""Provide the concrete blocklist repository implementation.
|
|
|
|
The blocklist_repo module uses structural typing to satisfy the BlocklistRepository
|
|
Protocol interface — its top-level async functions must match the Protocol
|
|
signatures exactly. This is documented in Backend-Development.md § 13.7.1.
|
|
"""
|
|
return cast("BlocklistRepository", blocklist_repo)
|
|
|
|
|
|
async def get_import_log_repo() -> ImportLogRepository:
|
|
"""Provide the concrete import log repository implementation.
|
|
|
|
The import_log_repo module uses structural typing to satisfy the ImportLogRepository
|
|
Protocol interface — its top-level async functions must match the Protocol
|
|
signatures exactly. This is documented in Backend-Development.md § 13.7.1.
|
|
"""
|
|
return cast("ImportLogRepository", import_log_repo)
|
|
|
|
|
|
async def get_import_run_repo() -> ImportRunRepository:
|
|
"""Provide the concrete import run repository implementation.
|
|
|
|
The import_run_repo module uses structural typing to satisfy the ImportRunRepository
|
|
Protocol interface for tracking blocklist imports for idempotency detection.
|
|
"""
|
|
return cast("ImportRunRepository", import_run_repo)
|
|
|
|
|
|
async def get_settings_repo() -> SettingsRepository:
|
|
"""Provide the concrete settings repository implementation.
|
|
|
|
The settings_repo module uses structural typing to satisfy the SettingsRepository
|
|
Protocol interface — its top-level async functions must match the Protocol
|
|
signatures exactly. This is documented in Backend-Development.md § 13.7.1.
|
|
"""
|
|
return cast("SettingsRepository", settings_repo)
|
|
|
|
|
|
async def get_history_archive_repo() -> HistoryArchiveRepository:
|
|
"""Provide the concrete history archive repository implementation.
|
|
|
|
The history_archive_repo module uses structural typing to satisfy the
|
|
HistoryArchiveRepository Protocol interface — its top-level async functions
|
|
must match the Protocol signatures exactly. This is documented in
|
|
Backend-Development.md § 13.7.1.
|
|
"""
|
|
return cast("HistoryArchiveRepository", history_archive_repo)
|
|
|
|
|
|
async def get_geo_cache_repo() -> GeoCacheRepository:
|
|
"""Provide the concrete geo cache repository implementation.
|
|
|
|
The geo_cache_repo module uses structural typing to satisfy the GeoCacheRepository
|
|
Protocol interface — its top-level async functions must match the Protocol
|
|
signatures exactly. This is documented in Backend-Development.md § 13.7.1.
|
|
"""
|
|
return cast("GeoCacheRepository", geo_cache_repo)
|
|
|
|
|
|
async def get_fail2ban_db_repo() -> Fail2BanDbRepository:
|
|
"""Provide the concrete fail2ban DB repository implementation.
|
|
|
|
The fail2ban_db_repo module uses structural typing to satisfy the
|
|
Fail2BanDbRepository Protocol interface — its top-level async functions must
|
|
match the Protocol signatures exactly. This is documented in
|
|
Backend-Development.md § 13.7.1.
|
|
"""
|
|
return cast("Fail2BanDbRepository", fail2ban_db_repo)
|
|
|
|
|
|
async def get_app_state(app_context: Annotated[ApplicationContext, Depends(get_app_context)]) -> ApplicationContext:
|
|
"""Provide the application state object for the current request."""
|
|
return app_context
|
|
|
|
|
|
async def get_app(request: Request) -> FastAPI:
|
|
"""Provide the FastAPI application instance for the current request."""
|
|
return request.app
|
|
|
|
|
|
async def get_server_status(app_context: Annotated[ApplicationContext, Depends(get_app_context)]) -> ServerStatus:
|
|
"""Return the cached fail2ban server status snapshot from application context."""
|
|
return app_context.server_status
|
|
|
|
|
|
async def get_pending_recovery(
|
|
app_context: Annotated[ApplicationContext, Depends(get_app_context)],
|
|
) -> PendingRecovery | None:
|
|
"""Return the current pending recovery record from application context."""
|
|
return app_context.pending_recovery
|
|
|
|
|
|
async def get_jail_service_state(
|
|
app_context: Annotated[ApplicationContext, Depends(get_app_context)],
|
|
) -> JailServiceState:
|
|
"""Return the jail service state holder from runtime state.
|
|
|
|
Returns:
|
|
The JailServiceState containing capability detection cache and
|
|
synchronization primitives for jail operations.
|
|
"""
|
|
return app_context.runtime_state.jail_service_state
|
|
|
|
|
|
async def get_health_probe() -> Callable[[str], Awaitable[ServerStatus]]:
|
|
"""Provide the health probe function for checking fail2ban connectivity.
|
|
|
|
Returns:
|
|
A callable that probes the fail2ban socket and returns ServerStatus.
|
|
This allows explicit dependency injection to avoid hidden service coupling.
|
|
"""
|
|
return health_service.probe
|
|
|
|
|
|
async def get_fail2ban_metadata_service() -> object:
|
|
"""Provide the Fail2BanMetadataService instance.
|
|
|
|
Returns:
|
|
The singleton Fail2BanMetadataService for resolving fail2ban metadata
|
|
(such as the database path) and caching results.
|
|
"""
|
|
return default_fail2ban_metadata_service
|
|
|
|
|
|
# -----------------------------------------------------------------------
|
|
# Service facade dependencies (db + repositories combined)
|
|
# These are for routers that need database access through services.
|
|
# Routers should depend on these instead of raw database connections.
|
|
# -----------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class SessionServiceContext:
|
|
"""Context for session-related database operations.
|
|
|
|
Combines the database connection and session repository so that
|
|
routers don't need to import DbDep directly.
|
|
"""
|
|
|
|
db: aiosqlite.Connection
|
|
session_repo: SessionRepository
|
|
|
|
|
|
async def get_session_service_context(
|
|
db: Annotated[aiosqlite.Connection, Depends(get_db)],
|
|
session_repo: Annotated[SessionRepository, Depends(get_session_repo)],
|
|
) -> SessionServiceContext:
|
|
"""Provide combined session database context for routers.
|
|
|
|
Args:
|
|
db: Request-scoped database connection.
|
|
session_repo: Session repository implementation.
|
|
|
|
Returns:
|
|
SessionServiceContext with both db and repository.
|
|
"""
|
|
return SessionServiceContext(db=db, session_repo=session_repo)
|
|
|
|
|
|
@dataclass
|
|
class BlocklistServiceContext:
|
|
"""Context for blocklist-related database operations.
|
|
|
|
Combines the database connection and blocklist-related repositories
|
|
so that routers don't need to import DbDep directly.
|
|
"""
|
|
|
|
db: aiosqlite.Connection
|
|
blocklist_repo: BlocklistRepository
|
|
import_log_repo: ImportLogRepository
|
|
settings_repo: SettingsRepository
|
|
|
|
|
|
async def get_blocklist_service_context(
|
|
db: Annotated[aiosqlite.Connection, Depends(get_db)],
|
|
blocklist_repo: Annotated[BlocklistRepository, Depends(get_blocklist_repo)],
|
|
import_log_repo: Annotated[ImportLogRepository, Depends(get_import_log_repo)],
|
|
settings_repo: Annotated[SettingsRepository, Depends(get_settings_repo)],
|
|
) -> BlocklistServiceContext:
|
|
"""Provide combined blocklist database context for routers.
|
|
|
|
Args:
|
|
db: Request-scoped database connection.
|
|
blocklist_repo: Blocklist repository implementation.
|
|
import_log_repo: Import log repository implementation.
|
|
settings_repo: Settings repository implementation.
|
|
|
|
Returns:
|
|
BlocklistServiceContext with db and all blocklist repositories.
|
|
"""
|
|
return BlocklistServiceContext(
|
|
db=db,
|
|
blocklist_repo=blocklist_repo,
|
|
import_log_repo=import_log_repo,
|
|
settings_repo=settings_repo,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class SettingsServiceContext:
|
|
"""Context for settings-related database operations.
|
|
|
|
Combines the database connection and settings repository so that
|
|
routers don't need to import DbDep directly.
|
|
"""
|
|
|
|
db: aiosqlite.Connection
|
|
settings_repo: SettingsRepository
|
|
|
|
|
|
async def get_settings_service_context(
|
|
db: Annotated[aiosqlite.Connection, Depends(get_db)],
|
|
settings_repo: Annotated[SettingsRepository, Depends(get_settings_repo)],
|
|
) -> SettingsServiceContext:
|
|
"""Provide combined settings database context for routers.
|
|
|
|
Args:
|
|
db: Request-scoped database connection.
|
|
settings_repo: Settings repository implementation.
|
|
|
|
Returns:
|
|
SettingsServiceContext with both db and repository.
|
|
"""
|
|
return SettingsServiceContext(db=db, settings_repo=settings_repo)
|
|
|
|
|
|
@dataclass
|
|
class BanServiceContext:
|
|
"""Context for ban-related database operations.
|
|
|
|
Combines the database connection and fail2ban DB repository.
|
|
"""
|
|
|
|
db: aiosqlite.Connection
|
|
fail2ban_db_repo: Fail2BanDbRepository
|
|
|
|
|
|
async def get_ban_service_context(
|
|
db: Annotated[aiosqlite.Connection, Depends(get_db)],
|
|
fail2ban_db_repo: Annotated[Fail2BanDbRepository, Depends(get_fail2ban_db_repo)],
|
|
) -> BanServiceContext:
|
|
"""Provide combined ban database context for routers.
|
|
|
|
Args:
|
|
db: Request-scoped database connection.
|
|
fail2ban_db_repo: Fail2Ban DB repository implementation.
|
|
|
|
Returns:
|
|
BanServiceContext with both db and repository.
|
|
"""
|
|
return BanServiceContext(db=db, fail2ban_db_repo=fail2ban_db_repo)
|
|
|
|
|
|
@dataclass
|
|
class HistoryServiceContext:
|
|
"""Context for history-related database operations.
|
|
|
|
Combines database connection and history-related repositories.
|
|
"""
|
|
|
|
db: aiosqlite.Connection
|
|
fail2ban_db_repo: Fail2BanDbRepository
|
|
history_archive_repo: HistoryArchiveRepository
|
|
|
|
|
|
async def get_history_service_context(
|
|
db: Annotated[aiosqlite.Connection, Depends(get_db)],
|
|
fail2ban_db_repo: Annotated[Fail2BanDbRepository, Depends(get_fail2ban_db_repo)],
|
|
history_archive_repo: Annotated[HistoryArchiveRepository, Depends(get_history_archive_repo)],
|
|
) -> HistoryServiceContext:
|
|
"""Provide combined history database context for routers.
|
|
|
|
Args:
|
|
db: Request-scoped database connection.
|
|
fail2ban_db_repo: Fail2Ban DB repository implementation.
|
|
history_archive_repo: History archive repository implementation.
|
|
|
|
Returns:
|
|
HistoryServiceContext with db and all history repositories.
|
|
"""
|
|
return HistoryServiceContext(
|
|
db=db,
|
|
fail2ban_db_repo=fail2ban_db_repo,
|
|
history_archive_repo=history_archive_repo,
|
|
)
|
|
|
|
|
|
# Internal database dependency for use by other dependencies only
|
|
# Routers should NOT import this - they should use repository dependencies instead
|
|
_DbDep = Annotated[aiosqlite.Connection, Depends(get_db)]
|
|
|
|
|
|
async def require_auth(
|
|
request: Request,
|
|
db: _DbDep,
|
|
settings: Annotated[Settings, Depends(get_settings)],
|
|
session_cache: Annotated[SessionCache, Depends(get_session_cache)],
|
|
session_repo: Annotated[SessionRepository, Depends(get_session_repo)],
|
|
) -> Session:
|
|
"""Validate the session token and return the active session.
|
|
|
|
The token is read from the ``bangui_session`` cookie or the
|
|
``Authorization: Bearer`` header.
|
|
|
|
Validated tokens may be cached in memory for a short period so that
|
|
concurrent requests sharing the same token avoid repeated SQLite
|
|
round-trips. This cache is disabled by default because process-local
|
|
invalidation is not safe in multi-worker or clustered deployments.
|
|
When enabled, entries are bypassed on expiry and explicitly cleared by
|
|
the configured session cache backend on logout.
|
|
|
|
Args:
|
|
request: The incoming FastAPI request.
|
|
db: Injected aiosqlite connection (for repository operations).
|
|
settings: Application settings used for signed session token validation.
|
|
session_cache: Session validation cache backend.
|
|
session_repo: Session repository for persistence operations.
|
|
|
|
Returns:
|
|
The active :class:`~app.models.auth.Session`.
|
|
|
|
Raises:
|
|
HTTPException: 401 if no valid session token is found.
|
|
"""
|
|
|
|
token: str | None = request.cookies.get(SESSION_COOKIE_NAME)
|
|
if not token:
|
|
auth_header: str = request.headers.get("Authorization", "")
|
|
if auth_header.startswith("Bearer "):
|
|
token = auth_header[len("Bearer "):]
|
|
|
|
if not token:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Authentication required.",
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
)
|
|
|
|
cache_enabled = _session_cache_enabled(settings)
|
|
if cache_enabled:
|
|
cached = session_cache.get(token)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
try:
|
|
session = await auth_service.validate_session(
|
|
db,
|
|
token,
|
|
settings.session_secret,
|
|
settings.session_secret_previous,
|
|
session_repo=session_repo,
|
|
)
|
|
except ValueError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail=str(exc),
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
) from exc
|
|
|
|
if cache_enabled:
|
|
session_cache.set(token, session, settings.session_cache_ttl_seconds)
|
|
return session
|
|
|
|
|
|
# Convenience type aliases for route signatures.
|
|
# NOTE: Database connections are NOT exported to routers. Routers should depend on
|
|
# repository dependencies (SessionRepoDep, BlocklistRepositoryDep, etc.) instead.
|
|
# See Backend-Development.md for the dependency layering rules.
|
|
|
|
SettingsDep = Annotated[Settings, Depends(get_settings)]
|
|
HttpSessionDep = Annotated[aiohttp.ClientSession, Depends(get_http_session)]
|
|
SchedulerDep = Annotated[AsyncIOScheduler, Depends(get_scheduler)]
|
|
Fail2BanSocketDep = Annotated[str, Depends(get_fail2ban_socket)]
|
|
Fail2BanConfigDirDep = Annotated[str, Depends(get_fail2ban_config_dir)]
|
|
Fail2BanStartCommandDep = Annotated[str, Depends(get_fail2ban_start_command)]
|
|
GeoCacheDep = Annotated[GeoCache, Depends(get_geo_cache)]
|
|
ServerStatusDep = Annotated[ServerStatus, Depends(get_server_status)]
|
|
PendingRecoveryDep = Annotated[PendingRecovery | None, Depends(get_pending_recovery)]
|
|
JailServiceStateDep = Annotated[JailServiceState, Depends(get_jail_service_state)]
|
|
HealthProbeDep = Annotated[Callable[[str], Awaitable[ServerStatus]], Depends(get_health_probe)]
|
|
SessionCacheDep = Annotated[SessionCache, Depends(get_session_cache)]
|
|
SessionRepoDep = Annotated[SessionRepository, Depends(get_session_repo)]
|
|
SettingsRepoDep = Annotated[SettingsRepository, Depends(get_settings_repo)]
|
|
HistoryArchiveRepositoryDep = Annotated[HistoryArchiveRepository, Depends(get_history_archive_repo)]
|
|
BlocklistRepositoryDep = Annotated[BlocklistRepository, Depends(get_blocklist_repo)]
|
|
ImportLogRepositoryDep = Annotated[ImportLogRepository, Depends(get_import_log_repo)]
|
|
ImportRunRepositoryDep = Annotated[ImportRunRepository, Depends(get_import_run_repo)]
|
|
GeoCacheRepositoryDep = Annotated[GeoCacheRepository, Depends(get_geo_cache_repo)]
|
|
Fail2BanDbRepositoryDep = Annotated[Fail2BanDbRepository, Depends(get_fail2ban_db_repo)]
|
|
AppStateDep = Annotated[ApplicationContext, Depends(get_app_state)]
|
|
AppDep = Annotated[FastAPI, Depends(get_app)]
|
|
AuthDep = Annotated[Session, Depends(require_auth)]
|
|
GlobalRateLimiterDep = Annotated[GlobalRateLimiter, Depends(get_global_rate_limiter)]
|
|
Fail2BanMetadataServiceDep = Annotated[Fail2BanMetadataService, Depends(get_fail2ban_metadata_service)]
|
|
|
|
# Service context dependencies (db + repositories combined for routers)
|
|
# Routers should use these instead of importing DbDep directly.
|
|
SessionServiceContextDep = Annotated[SessionServiceContext, Depends(get_session_service_context)]
|
|
BlocklistServiceContextDep = Annotated[BlocklistServiceContext, Depends(get_blocklist_service_context)]
|
|
SettingsServiceContextDep = Annotated[SettingsServiceContext, Depends(get_settings_service_context)]
|
|
BanServiceContextDep = Annotated[BanServiceContext, Depends(get_ban_service_context)]
|
|
HistoryServiceContextDep = Annotated[HistoryServiceContext, Depends(get_history_service_context)]
|
|
|
|
# DEPRECATED: DbDep is provided for backward compatibility only.
|
|
# DO NOT use in new code. Use repository dependencies instead (SessionRepoDep, BlocklistRepositoryDep, etc.)
|
|
# See Backend-Development.md § 6 for dependency layering rules.
|
|
DbDep = _DbDep
|