"""Request authentication + authorization dependencies. AUTH_MODE=entra: validates the bearer token, enforces the front-door group, and auto-provisions a local user (OID, name, email) on first login. AUTH_MODE=dev: trusts an X-Dev-User email header against seeded users. Local development only. """ import logging from dataclasses import dataclass, field from datetime import timedelta from fastapi import Depends, HTTPException, Request from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.auth.entra import AuthError, ensure_group_membership, validate_access_token from app.config import get_settings from app.database import get_db from app.domain import Role from app.models import User, UserRole from app.models.base import utcnow logger = logging.getLogger(__name__) _bearer = HTTPBearer(auto_error=False) DEV_DEFAULT_USER = "admin@pescoinc.biz" @dataclass class CurrentUser: user: User roles: set[str] = field(default_factory=set) token: str | None = None # raw API access token (used for Graph OBO) claims: dict = field(default_factory=dict) @property def id(self) -> int: return self.user.id @property def is_admin(self) -> bool: return Role.ADMIN.value in self.roles def has_role(self, *roles: Role) -> bool: return self.is_admin or any(r.value in self.roles for r in roles) async def _load_user_by_email(db: AsyncSession, email: str) -> User | None: result = await db.execute(select(User).where(User.email == email.lower())) return result.scalar_one_or_none() async def _provision_entra_user(db: AsyncSession, claims: dict) -> User: settings = get_settings() oid = claims.get("oid") or claims.get("sub") email = ( claims.get("preferred_username") or claims.get("email") or claims.get("upn") or "" ).lower() name = claims.get("name") or email or "Unknown User" employee_id = claims.get("employeeid") or claims.get("employee_id") user = ( await db.execute(select(User).where(User.entra_oid == oid)) ).scalar_one_or_none() if user is None and email: user = await _load_user_by_email(db, email) if user is not None and user.entra_oid is None: user.entra_oid = oid # link pre-seeded user to their Entra identity if user is None: if not email: raise AuthError("Token has no usable email/UPN claim.", 403) user = User( entra_oid=oid, email=email, display_name=name, employee_id=employee_id, ) db.add(user) try: await db.flush() db.add(UserRole(user_id=user.id, role=Role.REQUESTER.value)) if email in settings.initial_admin_email_set: db.add(UserRole(user_id=user.id, role=Role.ADMIN.value)) user.last_login_at = utcnow() await db.commit() logger.info("Auto-provisioned user %s", email) except IntegrityError: # Concurrent first login for the same user — use the winner's row. await db.rollback() user = ( await db.execute(select(User).where(User.entra_oid == oid)) ).scalar_one() await db.refresh(user) return user # Keep profile fresh; throttle last_login writes to one per 15 minutes. dirty = False if name and user.display_name != name: user.display_name = name dirty = True if email and user.email != email: user.email = email dirty = True if employee_id and user.employee_id != employee_id: user.employee_id = employee_id dirty = True if user.last_login_at is None or utcnow() - user.last_login_at > timedelta(minutes=15): user.last_login_at = utcnow() dirty = True if dirty: await db.commit() await db.refresh(user) return user async def get_current_user( request: Request, credentials: HTTPAuthorizationCredentials | None = Depends(_bearer), db: AsyncSession = Depends(get_db), ) -> CurrentUser: settings = get_settings() if settings.auth_mode == "dev": email = request.headers.get("X-Dev-User", DEV_DEFAULT_USER) user = await _load_user_by_email(db, email) if user is None or not user.is_active: raise HTTPException( status_code=401, detail=f"Unknown dev user '{email}'. Run `python -m app.seed` " "or pass a seeded email in the X-Dev-User header.", ) return CurrentUser(user=user, roles=set(user.roles), token=None, claims={}) if credentials is None: raise HTTPException(status_code=401, detail="Missing bearer token.") token = credentials.credentials try: claims = await validate_access_token(token) await ensure_group_membership(claims, token) except AuthError as exc: raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc user = await _provision_entra_user(db, claims) if not user.is_active: raise HTTPException(status_code=403, detail="This account has been deactivated.") return CurrentUser(user=user, roles=set(user.roles), token=token, claims=claims) def require_roles(*roles: Role): """Dependency factory: caller must hold one of `roles` (Admin always passes).""" async def dependency( current: CurrentUser = Depends(get_current_user), ) -> CurrentUser: if current.has_role(*roles): return current needed = ", ".join(r.value for r in roles) raise HTTPException( status_code=403, detail=f"This action requires one of the roles: {needed}." ) return dependency require_admin = require_roles(Role.ADMIN)