"""Guest management routes — invite, list, delete guests (admin only). Uses secure invitation tokens (P1.6 fix): - Token is a random 32-byte URL-safe string (secrets.token_urlsafe) - Only the SHA-256 hash is stored in the database - One-time use: used_at is set on acceptance - Session revocation via Redis on guest deletion """ from __future__ import annotations import hashlib import secrets import uuid from datetime import UTC, datetime, timedelta from fastapi import APIRouter, Depends, HTTPException, Request, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import get_settings from app.core.auth import get_redis, hash_password from app.core.db import get_db from app.deps import get_current_user, require_admin from app.models.guest_user import GuestUser from app.models.guest_invitation import GuestInvitation router = APIRouter(prefix="/api/v1/guests", tags=["guests"]) settings = get_settings() def _hash_token(token: str) -> str: """Hash a token using SHA-256.""" return hashlib.sha256(token.encode()).hexdigest() @router.post("/invite") async def invite_guest( request: Request, body: dict, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_admin), ): """Invite a guest user. Admin only. Returns a secure invitation token.""" email = body.get("email", "") name = body.get("name", "") expires_in_hours = body.get("expires_in_hours", 72) if not email or not name: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={"detail": "Email and name required", "code": "missing_fields"}, ) tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) expires_at = datetime.now(UTC) + timedelta(hours=expires_in_hours) # Check if guest already exists for this tenant existing_q = await db.execute( select(GuestUser) .where(GuestUser.email == email) .where(GuestUser.tenant_id == tenant_id) ) existing = existing_q.scalar_one_or_none() if existing: if existing.status == "active": raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail={"detail": "Guest already active", "code": "guest_exists"}, ) # Re-invite: update existing record and create new token existing.name = name existing.status = "invited" existing.invited_by = user_id existing.expires_at = expires_at existing.password_hash = None await db.flush() guest = existing else: guest = GuestUser( email=email, name=name, tenant_id=tenant_id, invited_by=user_id, status="invited", expires_at=expires_at, ) db.add(guest) await db.flush() # Generate secure invitation token raw_token = secrets.token_urlsafe(32) token_hash = _hash_token(raw_token) invitation = GuestInvitation( guest_user_id=guest.id, token_hash=token_hash, expires_at=expires_at, created_by=user_id, ) db.add(invitation) await db.commit() await db.refresh(guest) return { "id": str(guest.id), "email": guest.email, "name": guest.name, "status": guest.status, "expires_at": guest.expires_at.isoformat() if guest.expires_at else None, "invitation_token": raw_token, # Only returned once — not stored in plaintext } @router.post("/accept/{token}") async def accept_invitation( token: str, body: dict, db: AsyncSession = Depends(get_db), ): """Guest accepts invitation and sets password. Token is one-time use.""" password = body.get("password", "") if not password or len(password) < 8: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={"detail": "Password must be at least 8 characters", "code": "weak_password"}, ) # Hash the token and look up the invitation token_hash = _hash_token(token) inv_q = await db.execute( select(GuestInvitation) .where(GuestInvitation.token_hash == token_hash) .where(GuestInvitation.used_at.is_(None)) .where(GuestInvitation.revoked_at.is_(None)) ) invitation = inv_q.scalar_one_or_none() if not invitation: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail={"detail": "Invitation not found, already used, or revoked", "code": "invitation_not_found"}, ) # Check expiration if invitation.expires_at < datetime.now(UTC): raise HTTPException( status_code=status.HTTP_410_GONE, detail={"detail": "Invitation expired", "code": "invitation_expired"}, ) # Load guest guest_q = await db.execute( select(GuestUser).where(GuestUser.id == invitation.guest_user_id) ) guest = guest_q.scalar_one_or_none() if not guest or guest.status != "invited": raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail={"detail": "Guest account not found or already active", "code": "guest_not_found"}, ) # Set password and activate guest.password_hash = hash_password(password) guest.status = "active" invitation.used_at = datetime.now(UTC) # One-time use await db.commit() return {"message": "Invitation accepted", "status": "active"} @router.get("") async def list_guests( db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_admin), ): """List all guest users for the current tenant.""" tenant_id = uuid.UUID(current_user["tenant_id"]) result = await db.execute( select(GuestUser) .where(GuestUser.tenant_id == tenant_id) .order_by(GuestUser.created_at.desc()) ) guests = result.scalars().all() return [ { "id": str(g.id), "email": g.email, "name": g.name, "status": g.status, "expires_at": g.expires_at.isoformat() if g.expires_at else None, "created_at": g.created_at.isoformat() if g.created_at else None, } for g in guests ] @router.delete("/{guest_id}") async def delete_guest( guest_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_admin), ): """Delete/revoke a guest user and invalidate all sessions.""" tenant_id = uuid.UUID(current_user["tenant_id"]) try: gid = uuid.UUID(guest_id) except ValueError: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={"detail": "Invalid guest ID", "code": "invalid_id"}, ) guest_q = await db.execute( select(GuestUser).where(GuestUser.id == gid).where(GuestUser.tenant_id == tenant_id) ) guest = guest_q.scalar_one_or_none() if not guest: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail={"detail": "Guest not found", "code": "not_found"}, ) # Revoke: mark as revoked and clear password guest.status = "revoked" guest.password_hash = None # Revoke all pending invitations from sqlalchemy import update await db.execute( update(GuestInvitation) .where(GuestInvitation.guest_user_id == gid) .where(GuestInvitation.revoked_at.is_(None)) .values(revoked_at=datetime.now(UTC)) ) await db.commit() # Invalidate all active guest sessions via Redis redis = get_redis() if redis: # Find and delete all guest sessions for this user # Session keys are stored as session:{session_id} with guest_user_id inside # We use a Redis index: guest_sessions:{guest_user_id} → set of session_ids session_key = f"guest_sessions:{gid}" session_ids = await redis.smembers(session_key) if session_ids: for sid in session_ids: await redis.delete(f"session:{sid}") await redis.delete(session_key) return {"message": "Guest revoked, all sessions invalidated", "status": "revoked"}