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:
0
backend/app/auth/__init__.py
Normal file
0
backend/app/auth/__init__.py
Normal file
173
backend/app/auth/deps.py
Normal file
173
backend/app/auth/deps.py
Normal 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
124
backend/app/auth/entra.py
Normal 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
|
||||
)
|
||||
Reference in New Issue
Block a user