from __future__ import annotations import logging from typing import Callable import httpx from redis.asyncio import Redis from sqlalchemy import text from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from app.config import RuntimeSettings, Settings from app.models.intercept_log import InterceptLog logger = logging.getLogger(__name__) class AlertService: def __init__( self, settings: Settings, session_factory: async_sessionmaker[AsyncSession], redis: Redis | None, http_client: httpx.AsyncClient, runtime_settings_getter: Callable[[], RuntimeSettings], ) -> None: self.settings = settings self.session_factory = session_factory self.redis = redis self.http_client = http_client self.runtime_settings_getter = runtime_settings_getter def alert_key(self, token_hash: str) -> str: return f"sentinel:alert:{token_hash}" async def handle_intercept( self, token_hash: str, token_display: str, bound_ip: str, attempt_ip: str, ) -> None: await self._write_intercept_log(token_hash, token_display, bound_ip, attempt_ip) runtime_settings = self.runtime_settings_getter() if self.redis is None: logger.warning("Redis is unavailable. Intercept alert counters are disabled.") return try: async with self.redis.pipeline(transaction=True) as pipeline: pipeline.incr(self.alert_key(token_hash)) pipeline.expire(self.alert_key(token_hash), runtime_settings.alert_threshold_seconds) result = await pipeline.execute() except Exception: logger.warning("Failed to update intercept alert counter.", extra={"token_hash": token_hash}) return count = int(result[0]) if count < runtime_settings.alert_threshold_count: return payload = { "token_display": token_display, "attempt_ip": attempt_ip, "bound_ip": bound_ip, "count": count, } if runtime_settings.alert_webhook_url: try: await self.http_client.post(runtime_settings.alert_webhook_url, json=payload) except httpx.HTTPError: logger.exception("Failed to deliver alert webhook.", extra={"token_hash": token_hash}) try: await self.redis.delete(self.alert_key(token_hash)) except Exception: logger.warning("Failed to clear intercept alert counter.", extra={"token_hash": token_hash}) await self._mark_alerted_records(token_hash, runtime_settings.alert_threshold_seconds) async def _write_intercept_log( self, token_hash: str, token_display: str, bound_ip: str, attempt_ip: str, ) -> None: async with self.session_factory() as session: try: session.add( InterceptLog( token_hash=token_hash, token_display=token_display, bound_ip=bound_ip, attempt_ip=attempt_ip, alerted=False, ) ) await session.commit() except SQLAlchemyError: await session.rollback() logger.exception("Failed to write intercept log.", extra={"token_hash": token_hash}) async def _mark_alerted_records(self, token_hash: str, threshold_seconds: int) -> None: statement = text( """ UPDATE intercept_logs SET alerted = TRUE WHERE token_hash = :token_hash AND intercepted_at >= NOW() - (:threshold_seconds || ' seconds')::interval """ ) async with self.session_factory() as session: try: await session.execute( statement, {"token_hash": token_hash, "threshold_seconds": threshold_seconds}, ) await session.commit() except SQLAlchemyError: await session.rollback() logger.exception("Failed to mark intercept logs as alerted.", extra={"token_hash": token_hash})