Files
pesco-ncr/backend/app/services/graph.py
ang3l12 dea316b113 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>
2026-07-13 11:41:22 -06:00

92 lines
3.2 KiB
Python

"""Microsoft Graph helpers.
Delegated access uses the OAuth2 On-Behalf-Of (OBO) flow: the SPA sends the
API its access token (audience = this API); the API exchanges it with Entra ID
for a Graph token carrying the *signed-in user's* identity, so mail goes out
from that user's own mailbox. This keeps Graph scopes off the frontend and
needs no extra token plumbing on state-changing requests.
"""
import logging
from functools import partial
import anyio
import httpx
from app.config import get_settings
logger = logging.getLogger(__name__)
GRAPH_BASE = "https://graph.microsoft.com/v1.0"
MAIL_SEND_SCOPE = "https://graph.microsoft.com/Mail.Send"
GROUP_READ_SCOPE = "https://graph.microsoft.com/GroupMember.Read.All"
_cca = None
def _get_cca():
global _cca
if _cca is None:
import msal # imported lazily so tests never need Entra config
settings = get_settings()
_cca = msal.ConfidentialClientApplication(
settings.entra_client_id,
authority=f"https://login.microsoftonline.com/{settings.entra_tenant_id}",
client_credential=settings.entra_client_secret,
)
return _cca
def _acquire_obo_sync(user_token: str, scopes: list[str]) -> str:
result = _get_cca().acquire_token_on_behalf_of(
user_assertion=user_token, scopes=scopes
)
if "access_token" in result:
return result["access_token"]
raise RuntimeError(
f"OBO token exchange failed: {result.get('error')}: "
f"{result.get('error_description')}"
)
async def acquire_obo_token(user_token: str, scopes: list[str]) -> str:
"""Exchange the caller's API access token for a delegated Graph token."""
return await anyio.to_thread.run_sync(partial(_acquire_obo_sync, user_token, scopes))
async def send_mail_as_user(
user_token: str, subject: str, html_body: str, to_emails: list[str]
) -> None:
"""Send an email from the signed-in user's mailbox (delegated Mail.Send)."""
graph_token = await acquire_obo_token(user_token, [MAIL_SEND_SCOPE])
payload = {
"message": {
"subject": subject,
"body": {"contentType": "HTML", "content": html_body},
"toRecipients": [{"emailAddress": {"address": e}} for e in to_emails],
},
"saveToSentItems": True,
}
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.post(
f"{GRAPH_BASE}/me/sendMail",
json=payload,
headers={"Authorization": f"Bearer {graph_token}"},
)
if resp.status_code != 202:
raise RuntimeError(f"Graph sendMail returned {resp.status_code}: {resp.text[:300]}")
async def check_member_group(user_token: str, group_id: str) -> bool:
"""Group-overage fallback: ask Graph whether the signed-in user is in the
gate group. Requires delegated GroupMember.Read.All (see README)."""
graph_token = await acquire_obo_token(user_token, [GROUP_READ_SCOPE])
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.post(
f"{GRAPH_BASE}/me/checkMemberGroups",
json={"groupIds": [group_id]},
headers={"Authorization": f"Bearer {graph_token}"},
)
resp.raise_for_status()
return group_id in resp.json().get("value", [])