Files
pesco-ncr/backend/app/auth/deps.py

174 lines
5.8 KiB
Python
Raw Normal View History

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