"""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()