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:
91
backend/app/services/graph.py
Normal file
91
backend/app/services/graph.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""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", [])
|
||||
Reference in New Issue
Block a user