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

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
)