Backend Aug 22-25: AI analysis, welcome-packet templating, LetterStream, DocuSeal, staff RBAC + tier gate
- analysis.py: deterministic claim scorer + /analyze /approve /letter /advance-tier endpoints (auto-runs on intake) - packet.py + packet_fields.json: welcome-packet templating engine (6 onboarding docs, field catalog) - letterstream.py + letters.py: certified-mail send pipeline + letter lifecycle (webhook verified) - docuseal.py: DocuSeal signing integration - staff.py/models.py/schema.sql/auth.py: approval actor from staff key, tier gate (APPROVED+ACTIVE+onboarding docs), onboarding_docs table - frontend/: dependency-free static portal (intake, magic-link login/verify, dashboard) - landing-mockups/: 4 design-stance mockups + favicons - legal/: aup/privacy/sms-terms/terms HTML - docs/: letter-queue scope, letterstream API contract, 6 welcome-packet templates - review-dre-landing-2026-08-21.md: 3-variant landing feedback sprint - compliance/DRE_Compliance_Manual.md: updated Source synced from deployed /opt/dre-portal/app/ (was 4 days ahead of git).
This commit is contained in:
+157
-5
@@ -5,10 +5,14 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections import defaultdict, deque
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
@@ -108,6 +112,106 @@ def verify_staff_key(provided: str | None) -> bool:
|
||||
return _compare_hash(provided, key)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Stack Auth (auth2 / Hexclave) — per-user staff SSO
|
||||
# ---------------------------------------------------------------
|
||||
STACK_AUTH_API_URL = os.environ.get("STACK_AUTH_API_URL", "https://auth2-api.itpropartner.com").rstrip("/")
|
||||
STACK_AUTH_PUBLISHABLE_KEY = os.environ.get("STACK_AUTH_PUBLISHABLE_KEY", "")
|
||||
STACK_AUTH_PROJECT_ID = os.environ.get("STACK_AUTH_PROJECT_ID", "internal")
|
||||
STACK_AUTH_TEAM = os.environ.get("STACK_AUTH_TEAM", "dre-staff")
|
||||
# Emergency owner bypass: these emails are always admitted even if the team
|
||||
# membership API is down or membership was accidentally removed.
|
||||
STACK_AUTH_OWNER_EMAILS = [
|
||||
e.strip().lower()
|
||||
for e in os.environ.get("STACK_AUTH_OWNER_EMAILS", "").split(",")
|
||||
if e.strip()
|
||||
]
|
||||
|
||||
|
||||
def _stack_auth_request(method: str, path: str, access_token: str | None = None,
|
||||
body: dict | None = None):
|
||||
"""Call the auth2 Stack Auth REST API (client mode). Returns (status, parsed|raw).
|
||||
|
||||
Unauthenticated calls use the publishable client key; authenticated calls use
|
||||
the opaque access token instead. Both carry access-type/project-id headers.
|
||||
"""
|
||||
url = STACK_AUTH_API_URL + path
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"x-hexclave-access-type": "client",
|
||||
"x-hexclave-project-id": STACK_AUTH_PROJECT_ID,
|
||||
}
|
||||
if access_token:
|
||||
headers["x-hexclave-access-token"] = access_token
|
||||
else:
|
||||
headers["x-hexclave-publishable-client-key"] = STACK_AUTH_PUBLISHABLE_KEY
|
||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=8)
|
||||
raw = resp.read().decode("utf-8", "replace")
|
||||
code = resp.status
|
||||
except urllib.error.HTTPError as e:
|
||||
code = e.code
|
||||
raw = e.read().decode("utf-8", "replace")
|
||||
except Exception: # noqa: BLE001
|
||||
return None, None
|
||||
try:
|
||||
return code, json.loads(raw)
|
||||
except Exception: # noqa: BLE001
|
||||
return code, raw
|
||||
|
||||
|
||||
def stack_auth_user(access_token: str | None) -> tuple[str, dict | None]:
|
||||
"""Validate an opaque Stack Auth access token and confirm dre-staff membership.
|
||||
|
||||
Returns one of:
|
||||
("ok", {"name","email","user_id"}) — valid session AND team member
|
||||
("denied", None) — valid session but NOT in the team
|
||||
("invalid", None) — bad/expired token or API unreachable
|
||||
"""
|
||||
if not access_token or not STACK_AUTH_PUBLISHABLE_KEY:
|
||||
return "invalid", None
|
||||
code, user = _stack_auth_request("GET", "/api/latest/users/me", access_token=access_token)
|
||||
if code != 200 or not isinstance(user, dict):
|
||||
return "invalid", None
|
||||
q = urllib.parse.quote(STACK_AUTH_TEAM)
|
||||
code, teams = _stack_auth_request("GET", f"/api/latest/teams?user_id=me&query={q}", access_token=access_token)
|
||||
items = teams.get("items") if isinstance(teams, dict) else None
|
||||
member = (code == 200 and isinstance(items, list) and any(
|
||||
str(t.get("display_name", "")).lower() == STACK_AUTH_TEAM.lower() for t in items))
|
||||
email = user.get("primary_email") or ""
|
||||
is_owner = email.lower() in STACK_AUTH_OWNER_EMAILS
|
||||
if not member and not is_owner:
|
||||
return "denied", None
|
||||
return "ok", {
|
||||
"name": user.get("display_name") or email or "DRE Staff",
|
||||
"email": email,
|
||||
"user_id": user.get("id") or "",
|
||||
}
|
||||
|
||||
|
||||
def stack_auth_sign_in(email: str, password: str) -> tuple[str, dict | None]:
|
||||
"""Password sign-in against Stack Auth, gated to the dre-staff team.
|
||||
|
||||
Returns ("ok", {"token","name","email","user_id"}), ("invalid", None) for bad
|
||||
credentials, or ("denied", None) for a valid account outside the team.
|
||||
"""
|
||||
code, d = _stack_auth_request(
|
||||
"POST", "/api/latest/auth/password/sign-in",
|
||||
body={"email": email, "password": password},
|
||||
)
|
||||
if code != 200 or not isinstance(d, dict):
|
||||
return "invalid", None
|
||||
token = d.get("access_token")
|
||||
if not token:
|
||||
return "invalid", None
|
||||
state, user = stack_auth_user(token)
|
||||
if state != "ok":
|
||||
return "denied", None
|
||||
return "ok", {"token": token, **user}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# FastAPI dependencies
|
||||
# ---------------------------------------------------------------
|
||||
@@ -119,14 +223,62 @@ def get_client_ip(request: Request) -> str:
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def require_staff(request: Request) -> None:
|
||||
"""Staff-key auth dependency. Raises 403 if missing/wrong."""
|
||||
provided = request.headers.get("x-dre-staff-key")
|
||||
if not verify_staff_key(provided):
|
||||
def staff_identity(provided: str | None) -> str:
|
||||
"""Resolve the acting staff member's display name from the presented key.
|
||||
|
||||
Priority:
|
||||
1. DRE_STAFF_DIRECTORY — 'KEY=Name <email>' entries (one per line or
|
||||
semicolon-separated). Enables real per-user RBAC once each staff
|
||||
member has their own key.
|
||||
2. DRE_STAFF_NAME — a single default name for the shared-key case.
|
||||
3. "DRE Staff" fallback.
|
||||
"""
|
||||
name = os.environ.get("DRE_STAFF_NAME", "").strip()
|
||||
directory = os.environ.get("DRE_STAFF_DIRECTORY", "").strip()
|
||||
if directory and provided:
|
||||
for entry in directory.replace(";", "\n").splitlines():
|
||||
entry = entry.strip()
|
||||
if not entry or "=" not in entry:
|
||||
continue
|
||||
key, _, label = entry.partition("=")
|
||||
if key.strip() == provided:
|
||||
name = label.strip()
|
||||
break
|
||||
return name or "DRE Staff"
|
||||
|
||||
|
||||
def require_staff(request: Request) -> str:
|
||||
"""Staff auth dependency. Raises 403 if missing/wrong.
|
||||
|
||||
Priority:
|
||||
1. Stack Auth access token (`x-dre-access-token`) → per-user auth2 SSO,
|
||||
validated server-side and gated to the dre-staff team.
|
||||
2. Legacy staff key (`x-dre-staff-key`) → script/fallback access.
|
||||
|
||||
Returns the acting staff member's display name so callers can record the
|
||||
actor without manual name entry.
|
||||
"""
|
||||
token = request.headers.get("x-dre-access-token")
|
||||
if token:
|
||||
state, user = stack_auth_user(token)
|
||||
if state == "ok":
|
||||
return user["name"]
|
||||
if state == "denied":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={"code": "staff_forbidden", "message": "Account is not authorized for staff access."},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={"code": "forbidden", "message": "Valid staff key required."},
|
||||
detail={"code": "staff_auth_expired", "message": "Staff session expired. Please sign in again."},
|
||||
)
|
||||
provided = request.headers.get("x-dre-staff-key")
|
||||
if verify_staff_key(provided):
|
||||
return staff_identity(provided)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={"code": "forbidden", "message": "Valid staff credentials required."},
|
||||
)
|
||||
|
||||
|
||||
def require_client(request: Request) -> dict:
|
||||
|
||||
Reference in New Issue
Block a user