"""Invites, members and revocation (SPEC §3.1, ADR-011).""" import hashlib import secrets from collections.abc import Callable from dataclasses import dataclass from datetime import UTC, datetime, timedelta from enum import StrEnum from typing import Any, cast from sqlalchemy import CursorResult, delete, func, select, update from app.config import AccessSettings from app.db.models import AccessAttempt, Draft, FsmState, Invite, Role, User, utcnow from app.db.session import SessionMaker LAST_SEEN_EVERY = timedelta(minutes=5) ATTEMPT_WINDOW = timedelta(hours=1) class RedeemStatus(StrEnum): OK = "ok" ALREADY_MEMBER = "already_member" REJECTED = "rejected" # unknown, used or expired — one answer, no hints which tokens existed THROTTLED = "throttled" @dataclass(frozen=True) class TgUser: tg_id: int full_name: str username: str | None = None @dataclass(frozen=True) class CreatedInvite: token: str expires_at: datetime def hash_token(token: str) -> str: return hashlib.sha256(token.encode()).hexdigest() class AccessService: def __init__(self, sessions: SessionMaker, settings: AccessSettings, clock: Callable[[], datetime] = utcnow): self._sessions = sessions self._settings = settings self._clock = clock async def bootstrap_admins(self) -> None: """ADMIN_IDS become active admins in the DB; everybody else gets in only by invite (SPEC A-8).""" async with self._sessions.begin() as s: for tg_id in self._settings.admin_ids: user = await s.get(User, tg_id) if user is None: s.add(User(tg_id=tg_id, full_name=f"admin {tg_id}", role=Role.ADMIN, joined_at=self._clock())) else: user.role, user.is_active = Role.ADMIN, True async def get_member(self, tg_id: int) -> User | None: async with self._sessions() as s: user = await s.get(User, tg_id) return user if user is not None and user.is_active else None async def touch(self, member: User, seen: TgUser) -> None: """Refresh name and last-seen, at most every few minutes — not a write per update.""" now = self._clock() last = member.last_seen_at if ( last is not None and now - _aware(last) < LAST_SEEN_EVERY and (member.full_name, member.username) == (seen.full_name, seen.username) ): return async with self._sessions.begin() as s: await s.execute( update(User) .where(User.tg_id == member.tg_id) .values(last_seen_at=now, full_name=seen.full_name, username=seen.username) ) async def create_invite(self, admin_id: int) -> CreatedInvite: token = secrets.token_urlsafe(16) # 128 bits, 22 chars of [A-Za-z0-9_-]: fits the 64-char deep link payload expires_at = self._clock() + timedelta(hours=self._settings.invite_ttl_hours) async with self._sessions.begin() as s: s.add( Invite( token_hash=hash_token(token), created_by=admin_id, created_at=self._clock(), expires_at=expires_at ) ) return CreatedInvite(token=token, expires_at=expires_at) async def redeem(self, token: str, who: TgUser) -> RedeemStatus: now = self._clock() try: async with self._sessions.begin() as s: existing = await s.get(User, who.tg_id) if existing is not None and existing.is_active: return RedeemStatus.ALREADY_MEMBER failed = await s.scalar( select(func.count()) .select_from(AccessAttempt) .where( AccessAttempt.tg_id == who.tg_id, AccessAttempt.ok.is_(False), AccessAttempt.at > now - ATTEMPT_WINDOW, ) ) if (failed or 0) >= self._settings.max_failed_attempts: return RedeemStatus.THROTTLED invite = await s.scalar(select(Invite).where(Invite.token_hash == hash_token(token))) if invite is None or invite.used_by is not None or _aware(invite.expires_at) <= now: raise _Rejected if existing is None: s.add( User( tg_id=who.tg_id, full_name=who.full_name, username=who.username, role=Role.MEMBER, invited_by=invite.created_by, joined_at=now, last_seen_at=now, ) ) else: # revoked earlier, invited again existing.is_active, existing.role, existing.invited_by = True, Role.MEMBER, invite.created_by existing.full_name, existing.username, existing.last_seen_at = who.full_name, who.username, now await s.flush() # the user row must exist before invites.used_by points at it # Claim atomically: of two people scanning the same QR only one UPDATE matches the row, # the loser's transaction (with its freshly created user) is rolled back. result = await s.execute( update(Invite) .where(Invite.id == invite.id, Invite.used_by.is_(None)) .values(used_by=who.tg_id, used_at=now) ) if cast(CursorResult[Any], result).rowcount != 1: raise _Rejected s.add(AccessAttempt(tg_id=who.tg_id, at=now, ok=True)) except _Rejected: async with self._sessions.begin() as s: s.add(AccessAttempt(tg_id=who.tg_id, at=now, ok=False)) return RedeemStatus.REJECTED return RedeemStatus.OK async def list_members(self) -> list[User]: async with self._sessions() as s: rows = await s.scalars(select(User).where(User.is_active.is_(True)).order_by(User.role, User.joined_at)) return list(rows) async def revoke(self, tg_id: int) -> bool: """Cut a member off at once: their updates are ignored from now on, draft and dialog state are dropped.""" async with self._sessions.begin() as s: user = await s.get(User, tg_id) if user is None or not user.is_active or user.role is Role.ADMIN: return False user.is_active = False await s.execute(delete(Draft).where(Draft.user_id == tg_id)) # FSM keys are "bot:chat:user:…"; in a private chat chat == user await s.execute(delete(FsmState).where(FsmState.key.like(f"%:{tg_id}:{tg_id}:%"))) return True class _Rejected(Exception): pass def _aware(value: datetime) -> datetime: # SQLite hands back naive datetimes even for timezone=True columns; everything we store is UTC. return value if value.tzinfo is not None else value.replace(tzinfo=UTC)