Complete Non-Conformance Report system replacing the PowerApps/SharePoint prototype: FastAPI + SQLAlchemy 2 (async) + Alembic + MySQL 8 backend, React 18 + Vite + TypeScript + MUI frontend, Entra ID auth (MSAL / JWKS, group-gated), Microsoft Graph delegated Mail.Send notifications (OBO), six-stage workflow state machine with server-side enforcement, atomic NCR-YYYY-NNNN numbering, attachments with camera capture, immutable field-level audit trail, admin reopen, reports + CSV export, WeasyPrint PDF traveler, Power BI reporting views + read-only DB user, documented VISUAL ERP job-lookup stub, pytest suite (26 tests), docker-compose deployment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
174 lines
5.8 KiB
Python
174 lines
5.8 KiB
Python
"""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)
|