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).
104 lines
3.9 KiB
Python
104 lines
3.9 KiB
Python
"""Public unban-request intake + admin resolution. One request per IP per
|
|
24h (enforced here), notifying every admin account's own email (Resend if
|
|
configured, else SMTP - same provider selection as the rest of security
|
|
email, see security_email_service.resolve_provider) and via the existing
|
|
admin-only Redis log feed (app/core/logging.py), so it surfaces in the
|
|
Settings page's Logging box too.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import UTC, datetime
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.config import Settings
|
|
from app.core.errors import ConflictError, NotFoundError, RateLimitedError
|
|
from app.core.logging import get_logger
|
|
from app.models.ip_ban import IpBan
|
|
from app.notifications.base import NotificationMessage
|
|
from app.repositories.ip_throttle_repository import IpThrottleRepository
|
|
from app.repositories.unban_request_repository import UnbanRequestRepository
|
|
from app.repositories.user_repository import UserRepository
|
|
from app.services.security_email_service import resolve_provider
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
UNBAN_REQUEST_COOLDOWN_HOURS = 24
|
|
|
|
|
|
async def submit_unban_request(
|
|
db: AsyncSession, settings: Settings, ip_address: str, message: str | None
|
|
) -> None:
|
|
repo = UnbanRequestRepository(db)
|
|
if await repo.within_cooldown(ip_address, UNBAN_REQUEST_COOLDOWN_HOURS):
|
|
raise RateLimitedError(
|
|
f"Only one unban request is allowed per {UNBAN_REQUEST_COOLDOWN_HOURS} hours."
|
|
)
|
|
|
|
await repo.create(ip_address, message)
|
|
await db.commit()
|
|
|
|
logger.warning("unban_request_received", ip=ip_address, message=message or "")
|
|
|
|
admin_emails = await UserRepository(db).list_admin_emails()
|
|
if not admin_emails:
|
|
logger.warning("unban_request_no_admin_to_notify", ip=ip_address)
|
|
return
|
|
|
|
provider = resolve_provider(settings)
|
|
for admin_email in admin_emails:
|
|
email_message = NotificationMessage(
|
|
destination_value=admin_email,
|
|
subject=f"Unban request from {ip_address}",
|
|
body_text=f"IP: {ip_address}\n\nMessage:\n{message or '(none)'}",
|
|
)
|
|
await provider.send(email_message)
|
|
|
|
|
|
async def list_ip_bans(db: AsyncSession) -> list[IpBan]:
|
|
return await IpThrottleRepository(db).list_bans()
|
|
|
|
|
|
async def unban_ip(db: AsyncSession, ip_address: str) -> bool:
|
|
repo = IpThrottleRepository(db)
|
|
cleared = await repo.clear_ban_and_state(ip_address)
|
|
await db.commit()
|
|
return cleared
|
|
|
|
|
|
async def ban_ip(db: AsyncSession, ip_address: str, reason: str = "manual_admin_ban") -> IpBan:
|
|
"""Admin-initiated ban, bypassing the usual offense-count escalation
|
|
ladder (ip_throttle_service) entirely - a deliberate manual override,
|
|
not something the automated abuse-detection path produces."""
|
|
repo = IpThrottleRepository(db)
|
|
if await repo.get_ban(ip_address) is not None:
|
|
raise ConflictError(f"{ip_address} is already banned.")
|
|
ban = await repo.create_ban(ip_address, reason, datetime.now(UTC))
|
|
await db.commit()
|
|
return ban
|
|
|
|
|
|
async def accept_unban_request(db: AsyncSession, request_id: uuid.UUID) -> None:
|
|
"""Unbans the requester's IP and clears the request from the pending
|
|
queue - a real pardon (see clear_ban_and_state), not just acknowledging
|
|
the request was read."""
|
|
repo = UnbanRequestRepository(db)
|
|
request = await repo.get(request_id)
|
|
if request is None:
|
|
raise NotFoundError("Unban request not found.")
|
|
await IpThrottleRepository(db).clear_ban_and_state(request.ip_address)
|
|
await repo.delete(request_id)
|
|
await db.commit()
|
|
|
|
|
|
async def reject_unban_request(db: AsyncSession, request_id: uuid.UUID) -> None:
|
|
"""Dismisses the request without touching the ban - the IP stays
|
|
banned."""
|
|
repo = UnbanRequestRepository(db)
|
|
if await repo.get(request_id) is None:
|
|
raise NotFoundError("Unban request not found.")
|
|
await repo.delete(request_id)
|
|
await db.commit()
|