Initial commit: PESCO NCR system

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>
This commit is contained in:
ang3l12
2026-07-13 11:41:22 -06:00
commit dea316b113
111 changed files with 13817 additions and 0 deletions

View File

173
backend/app/auth/deps.py Normal file
View File

@@ -0,0 +1,173 @@
"""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)

124
backend/app/auth/entra.py Normal file
View File

@@ -0,0 +1,124 @@
"""Entra ID access-token validation (python-jose + tenant JWKS)."""
import logging
import time
import httpx
from jose import JWTError, jwt
from app.config import get_settings
logger = logging.getLogger(__name__)
class AuthError(Exception):
def __init__(self, message: str, status_code: int = 401):
self.message = message
self.status_code = status_code
super().__init__(message)
_jwks: dict[str, dict] = {}
_jwks_fetched_at: float = 0.0
_JWKS_TTL = 60 * 60 * 12
async def _fetch_jwks() -> None:
global _jwks, _jwks_fetched_at
settings = get_settings()
url = (
f"https://login.microsoftonline.com/{settings.entra_tenant_id}"
"/discovery/v2.0/keys"
)
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(url)
resp.raise_for_status()
_jwks = {k["kid"]: k for k in resp.json().get("keys", [])}
_jwks_fetched_at = time.time()
logger.info("Fetched %d Entra signing keys", len(_jwks))
async def _get_signing_key(kid: str) -> dict:
stale = time.time() - _jwks_fetched_at > _JWKS_TTL
if kid not in _jwks or stale:
await _fetch_jwks()
key = _jwks.get(kid)
if key is None:
raise AuthError("Token signed with an unknown key.")
return key
async def validate_access_token(token: str) -> dict:
"""Validate signature, expiry, audience, and issuer; return claims."""
settings = get_settings()
if not settings.entra_tenant_id or not settings.entra_client_id:
raise AuthError(
"Entra ID is not configured (ENTRA_TENANT_ID / ENTRA_CLIENT_ID).", 503
)
try:
header = jwt.get_unverified_header(token)
key = await _get_signing_key(header.get("kid", ""))
claims = jwt.decode(
token,
key,
algorithms=["RS256"],
options={"verify_aud": False}, # audience list checked below
)
except AuthError:
raise
except JWTError as exc:
raise AuthError(f"Invalid token: {exc}") from exc
aud = claims.get("aud")
if aud not in settings.api_audiences:
raise AuthError("Token audience does not match this API.")
tid = settings.entra_tenant_id
valid_issuers = {
f"https://login.microsoftonline.com/{tid}/v2.0",
f"https://sts.windows.net/{tid}/",
}
if claims.get("iss") not in valid_issuers:
raise AuthError("Token issuer does not match the configured tenant.")
return claims
async def ensure_group_membership(claims: dict, token: str) -> None:
"""Front-door gate: require membership in ENTRA_ALLOWED_GROUP_ID.
Uses the `groups` claim when present; on claim overage falls back to a
delegated Graph checkMemberGroups call.
"""
settings = get_settings()
group_id = settings.entra_allowed_group_id
if not group_id:
return # gate disabled by configuration
groups = claims.get("groups")
if groups is not None:
if group_id in groups:
return
raise AuthError(
"Your account is not a member of the NCR access group.", 403
)
claim_names = claims.get("_claim_names") or {}
if "groups" in claim_names:
# Group overage: too many groups to embed in the token.
from app.services.graph import check_member_group
try:
if await check_member_group(token, group_id):
return
except Exception as exc:
logger.warning("Group overage Graph check failed: %s", exc)
raise AuthError(
"Could not verify group membership (group overage). Ensure the "
"app has delegated GroupMember.Read.All consent, or scope the "
"group claim to 'Groups assigned to the application'.", 403
) from exc
raise AuthError("Your account is not a member of the NCR access group.", 403)
raise AuthError(
"Access token has no groups claim. Add the groups claim in the app "
"registration (Token configuration → Add groups claim).", 403
)