Initial commit: CI Agent competitive-intelligence monitoring app

FastAPI + Celery + Next.js + Postgres/Redis app with company monitoring,
source collection, LLM-based change analysis, enrichment, and account
security (Turnstile, escalating lockout, email verification).
This commit is contained in:
2026-08-05 10:48:20 -04:00
commit 1a4c80958f
365 changed files with 43541 additions and 0 deletions
@@ -0,0 +1,151 @@
"""Generic, per-IP escalation engine shared by resend-verification,
resend-password-reset, and failed-login throttling.
Two functions, not one, so login can enforce the gate *before* verifying a
password (a correct password during a penalty window must still be
rejected, or the delay is meaningless) without a check-then-act race:
- `peek_throttle` - read-only. Is this IP allowed to attempt `action` right
now? Also lazily resets a completed timeout cycle (memory of the offense
survives via `offense_count`; only the attempt stage resets - see
`IpThrottleState`).
- `record_attempt` - call only after the gated action actually happens (a
failed login, or a resend that's being sent). Advances the stage/backoff,
and escalates into a timeout (and eventually a permanent ban) once the
stage array is exhausted.
All time comparisons go through `_now()` so tests can monkeypatch it
directly to walk the whole escalation ladder in milliseconds of real time -
no waiting, no manual brute-forcing.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.base import ensure_aware_utc
from app.models.enums import ThrottleAction
from app.repositories.ip_throttle_repository import IpThrottleRepository
# 30s, 1min, 2min, 5min, 5min - shared identically by resend_verification and
# resend_reset. The *first* code send (at registration / at a reset request)
# also advances this, so "first resend allowed in 30s" is measured from that
# original send, not from a first manual resend click.
RESEND_BACKOFF_SECONDS: list[int] = [30, 60, 120, 300, 300]
# A stage's value is the wait *before the next* attempt (recording attempt K
# sets the delay gating attempt K+1) - so 5 truly free attempts (1-5, no
# wait before any of them) needs only 4 leading zeros (gating attempts
# 2-5), then the 7 real delays gate attempts 6-12 (5s/15s/30s/60s/2min/5min/
# 15min). Recording attempt 12 itself lands on stage_index=11, past the end
# of this 11-entry array - exhausted, which is exactly the intended "12th
# failure locks the account and starts the IP's timeout ladder" behavior.
LOGIN_BACKOFF_SECONDS: list[int] = [0, 0, 0, 0, 5, 15, 30, 60, 120, 300, 900]
# 30min, 1h, 2h, 3h, 4h, 5h - indexed by offense_count. Exceeding this length
# is a permanent ban.
TIMEOUT_LADDER_SECONDS: list[int] = [1800, 3600, 7200, 10800, 14400, 18000]
def _now() -> datetime:
return datetime.now(UTC)
@dataclass(frozen=True)
class ThrottleResult:
allowed: bool
retry_after_seconds: int | None = None
banned: bool = False
async def is_banned(db: AsyncSession, ip_address: str) -> bool:
"""Ban-only check, decoupled from any specific action's stage/timeout
state - for call sites like register() that have no throttle action of
their own but still must never let a banned IP through."""
return await IpThrottleRepository(db).get_ban(ip_address) is not None
async def peek_throttle(
db: AsyncSession, ip_address: str, action: ThrottleAction
) -> ThrottleResult:
repo = IpThrottleRepository(db)
ban = await repo.get_ban(ip_address)
if ban is not None:
return ThrottleResult(allowed=False, banned=True)
state = await repo.get_or_create_state(ip_address, action)
now = _now()
if state.timeout_until is not None:
timeout_until = ensure_aware_utc(state.timeout_until)
if timeout_until > now:
return ThrottleResult(
allowed=False, retry_after_seconds=int((timeout_until - now).total_seconds())
)
# Timeout has elapsed - cycle reset. offense_count (the memory of
# this IP's history) is deliberately left untouched.
state.timeout_until = None
state.attempt_count = 0
state.next_allowed_at = None
await db.flush()
if state.next_allowed_at is not None:
next_allowed_at = ensure_aware_utc(state.next_allowed_at)
if next_allowed_at > now:
return ThrottleResult(
allowed=False, retry_after_seconds=int((next_allowed_at - now).total_seconds())
)
return ThrottleResult(allowed=True)
async def record_attempt(
db: AsyncSession, ip_address: str, action: ThrottleAction, backoff_stages: list[int]
) -> None:
repo = IpThrottleRepository(db)
state = await repo.get_or_create_state(ip_address, action)
now = _now()
stage_index = state.attempt_count
state.attempt_count += 1
if stage_index < len(backoff_stages):
delay = backoff_stages[stage_index]
state.next_allowed_at = now + timedelta(seconds=delay)
await db.flush()
return
# Backoff stages exhausted - enter a timeout, escalating in length with
# each repeat offense.
if state.offense_count < len(TIMEOUT_LADDER_SECONDS):
duration = TIMEOUT_LADDER_SECONDS[state.offense_count]
state.timeout_until = now + timedelta(seconds=duration)
state.next_allowed_at = None
state.offense_count += 1
await db.flush()
return
# Offended again after exhausting the entire timeout ladder - permanent.
state.offense_count += 1
state.timeout_until = None
state.next_allowed_at = None
await repo.create_ban(ip_address, reason=action.value, banned_at=now)
await db.flush()
async def reset_on_success(db: AsyncSession, ip_address: str, action: ThrottleAction) -> None:
"""Login-only convenience: a correct login ends that specific attack
scenario for this IP, so the stage/cooldown resets - but `offense_count`
(this IP's history) is never cleared by a success, only by an admin
unban."""
repo = IpThrottleRepository(db)
state = await repo.get_state(ip_address, action)
if state is None:
return
state.attempt_count = 0
state.next_allowed_at = None
await db.flush()