Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- api/atp.py +429 -0
- api/auth.py +162 -33
- api/billing.py +512 -0
- api/identity.py +474 -0
- api/knowledge.py +262 -0
- api/ops.py +496 -0
- api/server.py +161 -38
api/atp.py
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ATP (Agentic Training Platform) API routes β see docs/ATP.md Β§5.
|
| 3 |
+
|
| 4 |
+
Mounted by api/server.py:
|
| 5 |
+
from api.atp import router as atp_router
|
| 6 |
+
app.include_router(atp_router)
|
| 7 |
+
|
| 8 |
+
Endpoints (all bearer-gated by the default auth middleware β api/auth.py):
|
| 9 |
+
GET /atp/data β full ATP_DATA blob (T0 public seed)
|
| 10 |
+
GET /atp/agents β agent roster
|
| 11 |
+
GET /atp/agents/{agent_id} β one agent profile (404 if unknown)
|
| 12 |
+
GET /atp/agents/{agent_id}/report.pdf β official report-card PDF
|
| 13 |
+
(Phase 6; any authenticated role;
|
| 14 |
+
live awards org-scoped; 404 if unknown)
|
| 15 |
+
GET /atp/certs β certification catalog
|
| 16 |
+
GET /atp/certs/{cert_id} β one cert (404 if unknown)
|
| 17 |
+
GET /atp/standard β the ATP standard (label grammar + principles)
|
| 18 |
+
GET /atp/marketplace β marketplace listings
|
| 19 |
+
GET /atp/hitl β HITL reward log, latest first (org-scoped)
|
| 20 |
+
POST /atp/hitl β body {agentId, layer, signal, reason, rater}
|
| 21 |
+
β appended (enriched) event [admin|sme]
|
| 22 |
+
GET /atp/requests β expert-composition requests (org-scoped)
|
| 23 |
+
POST /atp/requests β body {major, specialty, badgeIds, packId}
|
| 24 |
+
β appended request [admin|sme]
|
| 25 |
+
|
| 26 |
+
Certification engine (Phase 3 β docs/HARDENING.md):
|
| 27 |
+
POST /atp/exams/run β body {certId, candidateSpec?, agentId?,
|
| 28 |
+
dryRun=true} β {jobId} (poll /jobs/{id};
|
| 29 |
+
the job result is the award dict) [admin|sme]
|
| 30 |
+
GET /atp/awards β org-scoped cert awards, latest first
|
| 31 |
+
GET /atp/evidence/{id} β one signed evidence row (org-scoped; 404
|
| 32 |
+
cross-tenant/absent β no existence oracle)
|
| 33 |
+
GET /atp/evidence/{id}/verify β {ok, sigValid, chainPosition}
|
| 34 |
+
GET /atp/chain/verify β full evidence-chain verification for the org
|
| 35 |
+
|
| 36 |
+
Tenancy (Phase 2 β docs/TENANCY.md):
|
| 37 |
+
* The seed catalog (data/agents/certs/standard/marketplace) is T0 public:
|
| 38 |
+
read-only, identical for every org β never merged with tenant rows.
|
| 39 |
+
* atp_hitl / atp_requests are T1 org-internal: reads and writes go through
|
| 40 |
+
the store with org_id taken ONLY from the verified request state
|
| 41 |
+
(request.state.org_id, set by the auth middleware from the JWT) β never
|
| 42 |
+
from a client-supplied body/query value. Fallback 'org-demo' covers
|
| 43 |
+
BU_AUTH_DISABLED dev mode and legacy single-admin tokens.
|
| 44 |
+
* The demo seed rows (RL.hitlLog / EXPERT_REQUESTS) belong to 'org-demo'
|
| 45 |
+
(TENANCY.md leak surface #1), so they are appended only for that org.
|
| 46 |
+
* Writes require role admin|sme via api.auth.require_role; GET endpoints
|
| 47 |
+
are open to any authenticated role (viewer included). Under
|
| 48 |
+
BU_AUTH_DISABLED=1 the middleware runs every request as org-demo/admin,
|
| 49 |
+
so dev mode passes every role gate (pre-Phase-2 behavior preserved).
|
| 50 |
+
"""
|
| 51 |
+
|
| 52 |
+
from __future__ import annotations
|
| 53 |
+
|
| 54 |
+
import json
|
| 55 |
+
import threading
|
| 56 |
+
|
| 57 |
+
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
| 58 |
+
from pydantic import BaseModel
|
| 59 |
+
|
| 60 |
+
# require_role(*roles) β FastAPI dependency that 403s when the verified
|
| 61 |
+
# session role is not in `roles` (401 when the middleware never ran).
|
| 62 |
+
from api.auth import require_role
|
| 63 |
+
from atp import store, tenant_db
|
| 64 |
+
|
| 65 |
+
router = APIRouter(prefix="/atp")
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
# ββ Tenancy helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 69 |
+
|
| 70 |
+
def _org(request: Request) -> str:
|
| 71 |
+
"""Org for this request β ONLY from verified auth state (TENANCY.md Β§4
|
| 72 |
+
layer 1). 'org-demo' when auth is disabled / legacy single-admin token."""
|
| 73 |
+
return getattr(request.state, "org_id", None) or "org-demo"
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# ββ Read endpoints β T0 public seed ββββββββββββββββββββββββββββββββββββββββ
|
| 77 |
+
|
| 78 |
+
@router.get("/data")
|
| 79 |
+
def atp_data():
|
| 80 |
+
"""Full ATP_DATA blob (same shape as window.ATP_DATA, minus JS helpers).
|
| 81 |
+
T0 public seed β never merge tenant rows into it (TENANCY.md #9)."""
|
| 82 |
+
return store.get_data()
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@router.get("/agents")
|
| 86 |
+
def atp_agents():
|
| 87 |
+
return {"agents": store.get_data().get("AGENTS", [])}
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
@router.get("/agents/{agent_id}")
|
| 91 |
+
def atp_agent(agent_id: str):
|
| 92 |
+
for a in store.get_data().get("AGENTS", []):
|
| 93 |
+
if a.get("id") == agent_id:
|
| 94 |
+
return a
|
| 95 |
+
raise HTTPException(404, f"agent {agent_id} not found")
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
@router.get("/agents/{agent_id}/report.pdf")
|
| 99 |
+
def atp_agent_report_pdf(agent_id: str, request: Request):
|
| 100 |
+
"""Official report-card PDF (Phase 6, docs/HARDENING.md).
|
| 101 |
+
|
| 102 |
+
Any authenticated role β the PDF is built from the T0 public seed plus
|
| 103 |
+
the CALLER ORG's live cert awards (org_id from verified request state
|
| 104 |
+
only, TENANCY.md layer 1; atp/reportcard.py reads them through
|
| 105 |
+
atp/tenant_db.py). Served as an attachment download; 404 for agents
|
| 106 |
+
not in the seed roster. Deliberately NOT under /videos or /media β
|
| 107 |
+
those prefixes are public-cached (TENANCY.md leak surface #4).
|
| 108 |
+
"""
|
| 109 |
+
# Lazy import: reportlab stays an on-demand dependency β the server
|
| 110 |
+
# boots (and every other route works) even if it is not installed.
|
| 111 |
+
from atp import reportcard
|
| 112 |
+
|
| 113 |
+
try:
|
| 114 |
+
pdf = reportcard.build_report_card_pdf(agent_id, org_id=_org(request))
|
| 115 |
+
except reportcard.UnknownAgentError:
|
| 116 |
+
raise HTTPException(404, f"agent {agent_id} not found")
|
| 117 |
+
return Response(
|
| 118 |
+
content=pdf,
|
| 119 |
+
media_type="application/pdf",
|
| 120 |
+
headers={
|
| 121 |
+
"Content-Disposition":
|
| 122 |
+
f'attachment; filename="agent-{agent_id}-report-card.pdf"',
|
| 123 |
+
},
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
@router.get("/certs")
|
| 128 |
+
def atp_certs():
|
| 129 |
+
return {"certs": store.get_data().get("CERTS", [])}
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
@router.get("/certs/{cert_id}")
|
| 133 |
+
def atp_cert(cert_id: str):
|
| 134 |
+
for c in store.get_data().get("CERTS", []):
|
| 135 |
+
if c.get("id") == cert_id:
|
| 136 |
+
return c
|
| 137 |
+
raise HTTPException(404, f"cert {cert_id} not found")
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
@router.get("/standard")
|
| 141 |
+
def atp_standard():
|
| 142 |
+
return store.get_data().get("STANDARD", {})
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
@router.get("/marketplace")
|
| 146 |
+
def atp_marketplace():
|
| 147 |
+
return store.get_data().get("MARKETPLACE", {"listings": []})
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
# ββ HITL reward log β T1 org-internal ββββββββββββββββββββββββββββββββββββββ
|
| 151 |
+
|
| 152 |
+
@router.get("/hitl")
|
| 153 |
+
def atp_hitl_log(request: Request, limit: int = 100):
|
| 154 |
+
"""HITL reward log, latest first: the requesting org's live events
|
| 155 |
+
(store.read_hitl already returns latest-first) followed by the seed audit
|
| 156 |
+
history (stored oldest β newest, so reversed here). The seed rows belong
|
| 157 |
+
to 'org-demo' only β other tenants never see them."""
|
| 158 |
+
org = _org(request)
|
| 159 |
+
live = store.read_hitl(limit=limit, org_id=org)
|
| 160 |
+
seed = store.get_data().get("RL", {}).get("hitlLog", []) if org == "org-demo" else []
|
| 161 |
+
return {"events": (live + list(reversed(seed)))[:limit]}
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
class HitlBody(BaseModel):
|
| 165 |
+
agentId: str
|
| 166 |
+
layer: int
|
| 167 |
+
signal: int
|
| 168 |
+
reason: str = ""
|
| 169 |
+
rater: str = "anon"
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
@router.post("/hitl", dependencies=[Depends(require_role("admin", "sme"))])
|
| 173 |
+
def atp_hitl_submit(body: HitlBody, request: Request):
|
| 174 |
+
"""Append a human reward signal (role admin|sme). store.append_hitl
|
| 175 |
+
computes weightedDelta per the RL weighting in docs/ATP.md Β§1 and logs it
|
| 176 |
+
append-only under the caller's org."""
|
| 177 |
+
if not 1 <= body.layer <= 7:
|
| 178 |
+
raise HTTPException(400, "layer must be an integer in 1..7")
|
| 179 |
+
if body.signal not in (1, -1):
|
| 180 |
+
raise HTTPException(400, "signal must be 1 or -1")
|
| 181 |
+
return store.append_hitl({
|
| 182 |
+
"agentId": body.agentId,
|
| 183 |
+
"layer": body.layer,
|
| 184 |
+
"signal": body.signal,
|
| 185 |
+
"reason": body.reason,
|
| 186 |
+
"rater": body.rater,
|
| 187 |
+
}, org_id=_org(request))
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
# ββ Expert composition / train-to-order (docs/ATP.md Β§7) βββββββββββββββββββ
|
| 191 |
+
|
| 192 |
+
@router.get("/requests")
|
| 193 |
+
def atp_requests(request: Request, limit: int = 100):
|
| 194 |
+
"""Expert-composition requests: the requesting org's live commissions
|
| 195 |
+
(store.read_requests already returns latest-first) followed by the seed
|
| 196 |
+
board β which belongs to 'org-demo' only."""
|
| 197 |
+
org = _org(request)
|
| 198 |
+
live = store.read_requests(limit=limit, org_id=org)
|
| 199 |
+
seed = store.get_data().get("EXPERT_REQUESTS", []) if org == "org-demo" else []
|
| 200 |
+
return {"requests": live + seed}
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
class ComposeBody(BaseModel):
|
| 204 |
+
major: str
|
| 205 |
+
specialty: str = ""
|
| 206 |
+
badgeIds: list[str] = []
|
| 207 |
+
packId: str | None = None
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
@router.post("/requests", dependencies=[Depends(require_role("admin", "sme"))])
|
| 211 |
+
def atp_request_submit(body: ComposeBody, request: Request):
|
| 212 |
+
"""Commission an expert (role admin|sme β viewers are read-only,
|
| 213 |
+
TENANCY.md Β§Principals). store.append_request runs the matching rule
|
| 214 |
+
against seed agents, builds the pipeline stages, and appends the request
|
| 215 |
+
to the atp_requests table under the caller's org."""
|
| 216 |
+
return store.append_request({
|
| 217 |
+
"major": body.major,
|
| 218 |
+
"specialty": body.specialty,
|
| 219 |
+
"badgeIds": body.badgeIds,
|
| 220 |
+
"packId": body.packId,
|
| 221 |
+
}, org_id=_org(request))
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
# ββ Certification engine (Phase 3 β docs/HARDENING.md) βββββββββββββββββββββ
|
| 225 |
+
#
|
| 226 |
+
# Exam runs execute in the background (agents/jobs β they call the candidate
|
| 227 |
+
# AND the judge model, 30-120s live) and write signed, chained rows into the
|
| 228 |
+
# append-only atp_evidence / atp_cert_awards tables (migration 002; org_id +
|
| 229 |
+
# RLS from 005). atp/exams.py + atp/signing.py own the exam/signing logic;
|
| 230 |
+
# these routes are the org-scoped HTTP surface over them. Reads go through
|
| 231 |
+
# atp/tenant_db.py ONLY (TENANCY.md layer 2) with org_id from verified
|
| 232 |
+
# request state β never from the client payload.
|
| 233 |
+
|
| 234 |
+
_DB_READY = False
|
| 235 |
+
_DB_LOCK = threading.Lock()
|
| 236 |
+
_EVIDENCE_COLNAMES: set[str] | None = None
|
| 237 |
+
|
| 238 |
+
# camelCase key β snake_case column, same mapping idiom as atp/store.py.
|
| 239 |
+
_AWARD_COLS = {
|
| 240 |
+
"id": "id",
|
| 241 |
+
"ts": "ts",
|
| 242 |
+
"agentId": "agent_id",
|
| 243 |
+
"certId": "cert_id",
|
| 244 |
+
"score": "score",
|
| 245 |
+
"sectionScores": "section_scores",
|
| 246 |
+
"itemBreakdown": "item_breakdown",
|
| 247 |
+
"evidenceIds": "evidence_ids",
|
| 248 |
+
}
|
| 249 |
+
_AWARD_JSON_COLS = {"section_scores", "item_breakdown", "evidence_ids"}
|
| 250 |
+
|
| 251 |
+
_EVIDENCE_COLS = {
|
| 252 |
+
"id": "id",
|
| 253 |
+
"ts": "ts",
|
| 254 |
+
"agentId": "agent_id",
|
| 255 |
+
"certId": "cert_id",
|
| 256 |
+
"kind": "kind",
|
| 257 |
+
"payload": "payload",
|
| 258 |
+
"sig": "sig",
|
| 259 |
+
"prevHash": "prev_hash",
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def _ensure_db() -> None:
|
| 264 |
+
"""Engine + migrations up before the first direct tenant_db read here.
|
| 265 |
+
run_migrations() is idempotent (schema_migrations bookkeeping), so this
|
| 266 |
+
is a no-op when the server startup / atp.store already ran it."""
|
| 267 |
+
global _DB_READY
|
| 268 |
+
if _DB_READY:
|
| 269 |
+
return
|
| 270 |
+
with _DB_LOCK:
|
| 271 |
+
if not _DB_READY:
|
| 272 |
+
from atp import db
|
| 273 |
+
db.run_migrations()
|
| 274 |
+
_DB_READY = True
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def _loads_maybe(v):
|
| 278 |
+
"""json.loads a JSON-text column, tolerating NULL / non-JSON text."""
|
| 279 |
+
if v is None or not isinstance(v, str):
|
| 280 |
+
return v
|
| 281 |
+
try:
|
| 282 |
+
return json.loads(v)
|
| 283 |
+
except (json.JSONDecodeError, ValueError):
|
| 284 |
+
return v
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def _row_to_camel(row: dict, cols: dict, json_cols: set = frozenset()) -> dict:
|
| 288 |
+
return {key: (_loads_maybe(row.get(col)) if col in json_cols else row.get(col))
|
| 289 |
+
for key, col in cols.items()}
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
class ExamRunBody(BaseModel):
|
| 293 |
+
certId: str
|
| 294 |
+
candidateSpec: str | None = None # agents/backend.get_backend spec string
|
| 295 |
+
agentId: str | None = None
|
| 296 |
+
dryRun: bool = True # deterministic stub model (CI-safe)
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
@router.post("/exams/run", dependencies=[Depends(require_role("admin", "sme"))])
|
| 300 |
+
def atp_exam_run(body: ExamRunBody, request: Request):
|
| 301 |
+
"""Enqueue a certification exam run (role admin|sme).
|
| 302 |
+
|
| 303 |
+
Returns {jobId}; poll GET /jobs/{jobId} β when status='done' the job
|
| 304 |
+
result is the award dict (score, sectionScores, itemBreakdown,
|
| 305 |
+
evidenceIds, passedβ¦). The run itself enforces the ATP standard promises
|
| 306 |
+
(docs/ATP.md Β§2) inside atp/exams.py: judge/candidate model-family
|
| 307 |
+
separation and recorded seed/temperature/run-count reproducibility.
|
| 308 |
+
dryRun (default true) uses the deterministic stub model β no live LLM.
|
| 309 |
+
"""
|
| 310 |
+
from agents import jobs
|
| 311 |
+
|
| 312 |
+
org = _org(request)
|
| 313 |
+
cert_id = body.certId
|
| 314 |
+
if not any(c.get("id") == cert_id
|
| 315 |
+
for c in store.get_data().get("CERTS", [])):
|
| 316 |
+
raise HTTPException(404, f"cert {cert_id} not found")
|
| 317 |
+
|
| 318 |
+
candidate_spec = body.candidateSpec
|
| 319 |
+
agent_id = body.agentId
|
| 320 |
+
dry_run = body.dryRun
|
| 321 |
+
|
| 322 |
+
def _task():
|
| 323 |
+
from atp import exams
|
| 324 |
+
return exams.run_exam(
|
| 325 |
+
cert_id,
|
| 326 |
+
candidate_spec,
|
| 327 |
+
org_id=org,
|
| 328 |
+
dry_run=dry_run,
|
| 329 |
+
agent_id=agent_id,
|
| 330 |
+
)
|
| 331 |
+
|
| 332 |
+
job_id = jobs.submit("atp_exam", _task, org_id=org)
|
| 333 |
+
# jobId is the documented key; job_id keeps BU_API.runJob() compatible.
|
| 334 |
+
return {"jobId": job_id, "job_id": job_id}
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
@router.get("/awards")
|
| 338 |
+
def atp_awards(request: Request, limit: int = 100):
|
| 339 |
+
"""Org-scoped cert awards, latest first (any authenticated role)."""
|
| 340 |
+
org = _org(request)
|
| 341 |
+
_ensure_db()
|
| 342 |
+
sel = ", ".join(_AWARD_COLS.values())
|
| 343 |
+
rows = tenant_db.scoped_query(
|
| 344 |
+
org,
|
| 345 |
+
f"SELECT {sel} FROM atp_cert_awards"
|
| 346 |
+
f" WHERE org_id = :org ORDER BY id DESC LIMIT :n",
|
| 347 |
+
{"org": org, "n": int(limit)})
|
| 348 |
+
return {"awards": [_row_to_camel(r, _AWARD_COLS, _AWARD_JSON_COLS)
|
| 349 |
+
for r in rows]}
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def _evidence_where() -> str:
|
| 353 |
+
"""Row-match clause for a path id. The surrogate pk is an integer
|
| 354 |
+
(BIGSERIAL / AUTOINCREMENT β migration 002) so match its text form; if
|
| 355 |
+
the schema also carries a string business id (evidence_id), accept that
|
| 356 |
+
too so award.evidenceIds resolve regardless of which form they use."""
|
| 357 |
+
global _EVIDENCE_COLNAMES
|
| 358 |
+
if _EVIDENCE_COLNAMES is None:
|
| 359 |
+
from sqlalchemy import inspect as sa_inspect
|
| 360 |
+
|
| 361 |
+
from atp import db
|
| 362 |
+
_EVIDENCE_COLNAMES = {
|
| 363 |
+
c["name"] for c in sa_inspect(db.get_engine()).get_columns("atp_evidence")}
|
| 364 |
+
if "evidence_id" in _EVIDENCE_COLNAMES:
|
| 365 |
+
return "(evidence_id = :eid OR CAST(id AS TEXT) = :eid)"
|
| 366 |
+
return "CAST(id AS TEXT) = :eid"
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
def _evidence_row(org: str, evidence_id: str) -> dict | None:
|
| 370 |
+
"""One org-scoped evidence row (raw snake_case columns), or None."""
|
| 371 |
+
_ensure_db()
|
| 372 |
+
rows = tenant_db.scoped_query(
|
| 373 |
+
org,
|
| 374 |
+
f"SELECT * FROM atp_evidence"
|
| 375 |
+
f" WHERE org_id = :org AND {_evidence_where()} LIMIT 1",
|
| 376 |
+
{"org": org, "eid": str(evidence_id)})
|
| 377 |
+
return rows[0] if rows else None
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
@router.get("/evidence/{evidence_id}")
|
| 381 |
+
def atp_evidence(evidence_id: str, request: Request):
|
| 382 |
+
"""One signed evidence row. Cross-tenant ids 404 with the SAME message
|
| 383 |
+
as unknown ids β no existence oracle (TENANCY.md #6)."""
|
| 384 |
+
row = _evidence_row(_org(request), evidence_id)
|
| 385 |
+
if row is None:
|
| 386 |
+
raise HTTPException(404, f"evidence {evidence_id} not found")
|
| 387 |
+
return _row_to_camel(row, _EVIDENCE_COLS, {"payload"})
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
@router.get("/evidence/{evidence_id}/verify")
|
| 391 |
+
def atp_evidence_verify(evidence_id: str, request: Request):
|
| 392 |
+
"""Verify one evidence row: {ok, sigValid, chainPosition}.
|
| 393 |
+
|
| 394 |
+
sigValid β HMAC signature over the row's canonical payload checks
|
| 395 |
+
out (atp.signing.verify_row).
|
| 396 |
+
chainPosition β 1-based position of the row in the org's evidence chain.
|
| 397 |
+
ok β sigValid AND the whole org chain verifies (a valid row
|
| 398 |
+
inside a tampered ledger is NOT ok).
|
| 399 |
+
"""
|
| 400 |
+
from atp import signing
|
| 401 |
+
|
| 402 |
+
org = _org(request)
|
| 403 |
+
row = _evidence_row(org, evidence_id)
|
| 404 |
+
if row is None:
|
| 405 |
+
raise HTTPException(404, f"evidence {evidence_id} not found")
|
| 406 |
+
|
| 407 |
+
sig_valid = bool(signing.verify_row(row))
|
| 408 |
+
position = tenant_db.scoped_query(
|
| 409 |
+
org,
|
| 410 |
+
"SELECT COUNT(*) AS n FROM atp_evidence"
|
| 411 |
+
" WHERE org_id = :org AND id <= :rid",
|
| 412 |
+
{"org": org, "rid": row["id"]})[0]["n"]
|
| 413 |
+
chain = signing.verify_chain(org)
|
| 414 |
+
chain_ok = bool(chain.get("ok")) if isinstance(chain, dict) else bool(chain)
|
| 415 |
+
return {"ok": sig_valid and chain_ok,
|
| 416 |
+
"sigValid": sig_valid,
|
| 417 |
+
"chainPosition": position}
|
| 418 |
+
|
| 419 |
+
|
| 420 |
+
@router.get("/chain/verify")
|
| 421 |
+
def atp_chain_verify(request: Request):
|
| 422 |
+
"""Verify the caller org's whole evidence chain (sig + prev_hash links).
|
| 423 |
+
Returns atp.signing.verify_chain's result dict (tamper-evidence promise,
|
| 424 |
+
docs/HARDENING.md Phase 3)."""
|
| 425 |
+
from atp import signing
|
| 426 |
+
|
| 427 |
+
_ensure_db()
|
| 428 |
+
result = signing.verify_chain(_org(request))
|
| 429 |
+
return result if isinstance(result, dict) else {"ok": bool(result)}
|
api/auth.py
CHANGED
|
@@ -1,22 +1,34 @@
|
|
| 1 |
"""
|
| 2 |
Bearer-token authentication for the Brain University FastAPI app.
|
| 3 |
|
| 4 |
-
|
| 5 |
-
<username>|<expiry_unix>|<hmac_sha256(username|expiry, BU_SECRET)>
|
| 6 |
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
Env vars (read at process start):
|
| 10 |
BU_AUTH_USER β single allowed username (default "admin")
|
| 11 |
-
BU_AUTH_PASSWORD β
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
| 14 |
BU_AUTH_TTL_HOURS β token lifetime, default 24
|
| 15 |
-
BU_AUTH_DISABLED β "1" to bypass auth entirely (dev only
|
|
|
|
|
|
|
| 16 |
|
| 17 |
Routes protected by middleware are everything except an allowlist:
|
| 18 |
-
/health, /login, /
|
| 19 |
-
OPTIONS preflight.
|
| 20 |
"""
|
| 21 |
|
| 22 |
from __future__ import annotations
|
|
@@ -26,8 +38,9 @@ import hashlib
|
|
| 26 |
import hmac
|
| 27 |
import os
|
| 28 |
import secrets
|
|
|
|
| 29 |
import time
|
| 30 |
-
import
|
| 31 |
|
| 32 |
from fastapi import HTTPException, Request
|
| 33 |
|
|
@@ -40,29 +53,71 @@ AUTH_SECRET = os.environ.get("BU_AUTH_SECRET", "").encode("utf-8")
|
|
| 40 |
AUTH_TTL_S = int(os.environ.get("BU_AUTH_TTL_HOURS", "24")) * 3600
|
| 41 |
AUTH_DISABLED = os.environ.get("BU_AUTH_DISABLED", "") == "1"
|
| 42 |
|
| 43 |
-
if
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
"BU_AUTH_PASSWORD not set; defaulting to 'admin/password'. "
|
| 57 |
-
"Set BU_AUTH_PASSWORD before exposing this server publicly.",
|
| 58 |
-
RuntimeWarning,
|
| 59 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
|
| 62 |
# ββ Token mint / verify ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
def mint_token(username: str, ttl_s: int | None = None) -> str:
|
| 65 |
-
"""Issue a fresh bearer token."""
|
| 66 |
ttl = ttl_s or AUTH_TTL_S
|
| 67 |
expiry = int(time.time()) + ttl
|
| 68 |
payload = f"{username}|{expiry}"
|
|
@@ -71,8 +126,37 @@ def mint_token(username: str, ttl_s: int | None = None) -> str:
|
|
| 71 |
return base64.urlsafe_b64encode(raw.encode()).decode().rstrip("=")
|
| 72 |
|
| 73 |
|
| 74 |
-
def verify_token(token: str) ->
|
| 75 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
if not token:
|
| 77 |
return None
|
| 78 |
try:
|
|
@@ -109,6 +193,7 @@ def check_password(username: str, password: str) -> bool:
|
|
| 109 |
|
| 110 |
PUBLIC_PATHS = {
|
| 111 |
"/", "/health", "/login",
|
|
|
|
| 112 |
"/docs", "/openapi.json", "/redoc",
|
| 113 |
}
|
| 114 |
PUBLIC_PREFIXES = ("/videos/", "/media/") # static cached media
|
|
@@ -125,9 +210,22 @@ def is_public(path: str, method: str) -> bool:
|
|
| 125 |
return False
|
| 126 |
|
| 127 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
async def auth_middleware(request: Request, call_next):
|
| 129 |
"""FastAPI ASGI middleware β reject unauthenticated requests."""
|
| 130 |
if AUTH_DISABLED:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
return await call_next(request)
|
| 132 |
if is_public(request.url.path, request.method):
|
| 133 |
return await call_next(request)
|
|
@@ -140,13 +238,44 @@ async def auth_middleware(request: Request, call_next):
|
|
| 140 |
headers={"WWW-Authenticate": "Bearer"},
|
| 141 |
)
|
| 142 |
token = auth_hdr.split(" ", 1)[1].strip()
|
| 143 |
-
|
| 144 |
-
if not
|
| 145 |
from fastapi.responses import JSONResponse
|
| 146 |
return JSONResponse(
|
| 147 |
{"detail": "invalid or expired token"}, status_code=401,
|
| 148 |
headers={"WWW-Authenticate": "Bearer"},
|
| 149 |
)
|
| 150 |
-
# Pass
|
| 151 |
-
request
|
| 152 |
return await call_next(request)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
Bearer-token authentication for the Brain University FastAPI app.
|
| 3 |
|
| 4 |
+
Two token formats are accepted (Phase 2, docs/TENANCY.md):
|
|
|
|
| 5 |
|
| 6 |
+
* Org session JWT (api/identity.py) β HS256 over BU_AUTH_SECRET, claims
|
| 7 |
+
{sub, org_id, role, exp}. Tried FIRST by verify_token().
|
| 8 |
+
* Legacy single-admin HMAC token (URL-safe base64):
|
| 9 |
+
<username>|<expiry_unix>|<hmac_sha256(username|expiry, BU_SECRET)>
|
| 10 |
+
Fallback β kept for backward compatibility with the deployed demo; a
|
| 11 |
+
legacy token maps to the bootstrap org 'org-demo' with role 'admin'.
|
| 12 |
+
|
| 13 |
+
Stateless, no DB. Verification recomputes the HMAC / JWT signature and
|
| 14 |
+
checks expiry. On success the middleware sets request.state.user /
|
| 15 |
+
.user_id / .org_id / .role; route handlers gate roles with require_role().
|
| 16 |
|
| 17 |
Env vars (read at process start):
|
| 18 |
BU_AUTH_USER β single allowed username (default "admin")
|
| 19 |
+
BU_AUTH_PASSWORD β REQUIRED; process refuses to start without it
|
| 20 |
+
(unless BU_AUTH_DISABLED=1)
|
| 21 |
+
BU_AUTH_SECRET β HMAC key; REQUIRED; process refuses to start without
|
| 22 |
+
it (unless BU_AUTH_DISABLED=1, which uses an ephemeral
|
| 23 |
+
random key β tokens invalidate on restart)
|
| 24 |
BU_AUTH_TTL_HOURS β token lifetime, default 24
|
| 25 |
+
BU_AUTH_DISABLED β "1" to bypass auth entirely (dev only; used by
|
| 26 |
+
scripts/demo_start.sh). Requests run as
|
| 27 |
+
org-demo/admin so org-scoped routes keep working.
|
| 28 |
|
| 29 |
Routes protected by middleware are everything except an allowlist:
|
| 30 |
+
/health, /login, /auth/login, /auth/oidc/{login,callback}, /docs,
|
| 31 |
+
/openapi.json, /redoc, /videos/{...}, /media/{...}, OPTIONS preflight.
|
| 32 |
"""
|
| 33 |
|
| 34 |
from __future__ import annotations
|
|
|
|
| 38 |
import hmac
|
| 39 |
import os
|
| 40 |
import secrets
|
| 41 |
+
import sys
|
| 42 |
import time
|
| 43 |
+
from typing import NamedTuple
|
| 44 |
|
| 45 |
from fastapi import HTTPException, Request
|
| 46 |
|
|
|
|
| 53 |
AUTH_TTL_S = int(os.environ.get("BU_AUTH_TTL_HOURS", "24")) * 3600
|
| 54 |
AUTH_DISABLED = os.environ.get("BU_AUTH_DISABLED", "") == "1"
|
| 55 |
|
| 56 |
+
if AUTH_DISABLED:
|
| 57 |
+
# Explicit dev/demo opt-in (scripts/demo_start.sh, .claude/launch.json).
|
| 58 |
+
# Keep usable defaults so the login gate (which always POSTs /login)
|
| 59 |
+
# works in demo mode without any env setup.
|
| 60 |
+
print(
|
| 61 |
+
"=" * 72 + "\n"
|
| 62 |
+
"WARNING: BU_AUTH_DISABLED=1 β AUTHENTICATION IS BYPASSED (dev/demo\n"
|
| 63 |
+
"only). The login gate accepts the dev default credentials, and the\n"
|
| 64 |
+
"HMAC key is ephemeral (all tokens invalidate on restart). NEVER set\n"
|
| 65 |
+
"BU_AUTH_DISABLED in production. Unset it and provide\n"
|
| 66 |
+
"BU_AUTH_PASSWORD + BU_AUTH_SECRET to re-enable auth.\n"
|
| 67 |
+
+ "=" * 72,
|
| 68 |
+
file=sys.stderr,
|
|
|
|
|
|
|
|
|
|
| 69 |
)
|
| 70 |
+
if not AUTH_SECRET:
|
| 71 |
+
AUTH_SECRET = secrets.token_bytes(32) # ephemeral; fine for dev
|
| 72 |
+
if not AUTH_PASSWORD:
|
| 73 |
+
AUTH_PASSWORD = "password" # dev-only default; enabled path has NO default
|
| 74 |
+
else:
|
| 75 |
+
_missing = [name for name, val in (
|
| 76 |
+
("BU_AUTH_PASSWORD", AUTH_PASSWORD),
|
| 77 |
+
("BU_AUTH_SECRET", AUTH_SECRET),
|
| 78 |
+
) if not val]
|
| 79 |
+
if _missing:
|
| 80 |
+
raise RuntimeError(
|
| 81 |
+
"Refusing to start: required auth env var(s) unset: "
|
| 82 |
+
+ ", ".join(_missing)
|
| 83 |
+
+ ". Set them in the deployment environment (see render.yaml / "
|
| 84 |
+
"DEPLOY.md), or export BU_AUTH_DISABLED=1 for local dev only "
|
| 85 |
+
"(scripts/demo_start.sh does this)."
|
| 86 |
+
)
|
| 87 |
+
if len(AUTH_SECRET) < 32:
|
| 88 |
+
# HS256 signing key β short keys are brute-forceable. Same fail-loud
|
| 89 |
+
# posture as the missing-var check above.
|
| 90 |
+
raise RuntimeError(
|
| 91 |
+
"Refusing to start: BU_AUTH_SECRET is shorter than 32 bytes. "
|
| 92 |
+
"Generate one with: python3 -c \"import secrets; "
|
| 93 |
+
"print(secrets.token_urlsafe(48))\""
|
| 94 |
+
)
|
| 95 |
|
| 96 |
|
| 97 |
# ββ Token mint / verify ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 98 |
|
| 99 |
+
# Bootstrap org for legacy single-admin tokens (seeded by migrations/004).
|
| 100 |
+
LEGACY_ORG_ID = "org-demo"
|
| 101 |
+
LEGACY_ROLE = "admin"
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
class Principal(NamedTuple):
|
| 105 |
+
"""Verified caller identity, copied onto request.state by the middleware.
|
| 106 |
+
|
| 107 |
+
user β display identifier (org users: user id / legacy: username)
|
| 108 |
+
user_id β users.user_id for org sessions; None for legacy tokens
|
| 109 |
+
org_id β tenant id (legacy tokens map to 'org-demo')
|
| 110 |
+
role β admin | sme | viewer (legacy tokens map to 'admin')
|
| 111 |
+
"""
|
| 112 |
+
|
| 113 |
+
user: str
|
| 114 |
+
user_id: str | None
|
| 115 |
+
org_id: str
|
| 116 |
+
role: str
|
| 117 |
+
|
| 118 |
+
|
| 119 |
def mint_token(username: str, ttl_s: int | None = None) -> str:
|
| 120 |
+
"""Issue a fresh LEGACY bearer token (single-admin HMAC format)."""
|
| 121 |
ttl = ttl_s or AUTH_TTL_S
|
| 122 |
expiry = int(time.time()) + ttl
|
| 123 |
payload = f"{username}|{expiry}"
|
|
|
|
| 126 |
return base64.urlsafe_b64encode(raw.encode()).decode().rstrip("=")
|
| 127 |
|
| 128 |
|
| 129 |
+
def verify_token(token: str) -> Principal | None:
|
| 130 |
+
"""Verify a bearer token of either format β Principal, or None.
|
| 131 |
+
|
| 132 |
+
Order: org session JWT (api.identity.verify_session) first, then the
|
| 133 |
+
legacy single-admin HMAC token β which maps to org-demo/admin so the
|
| 134 |
+
pre-Phase-2 deployed demo keeps working unchanged.
|
| 135 |
+
"""
|
| 136 |
+
if not token:
|
| 137 |
+
return None
|
| 138 |
+
try:
|
| 139 |
+
from api import identity
|
| 140 |
+
except ImportError: # identity deps not installed β legacy still works
|
| 141 |
+
identity = None
|
| 142 |
+
if identity is not None:
|
| 143 |
+
claims = identity.verify_session(token)
|
| 144 |
+
if claims:
|
| 145 |
+
return Principal(
|
| 146 |
+
user=claims["sub"],
|
| 147 |
+
user_id=claims["sub"],
|
| 148 |
+
org_id=claims["org_id"],
|
| 149 |
+
role=claims["role"],
|
| 150 |
+
)
|
| 151 |
+
username = _verify_legacy_token(token)
|
| 152 |
+
if username is not None:
|
| 153 |
+
return Principal(user=username, user_id=None,
|
| 154 |
+
org_id=LEGACY_ORG_ID, role=LEGACY_ROLE)
|
| 155 |
+
return None
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def _verify_legacy_token(token: str) -> str | None:
|
| 159 |
+
"""Return username if the legacy HMAC token is valid + unexpired."""
|
| 160 |
if not token:
|
| 161 |
return None
|
| 162 |
try:
|
|
|
|
| 193 |
|
| 194 |
PUBLIC_PATHS = {
|
| 195 |
"/", "/health", "/login",
|
| 196 |
+
"/auth/login", "/auth/oidc/login", "/auth/oidc/callback",
|
| 197 |
"/docs", "/openapi.json", "/redoc",
|
| 198 |
}
|
| 199 |
PUBLIC_PREFIXES = ("/videos/", "/media/") # static cached media
|
|
|
|
| 210 |
return False
|
| 211 |
|
| 212 |
|
| 213 |
+
def _set_principal(request: Request, principal: Principal) -> None:
|
| 214 |
+
request.state.user = principal.user
|
| 215 |
+
request.state.user_id = principal.user_id
|
| 216 |
+
request.state.org_id = principal.org_id
|
| 217 |
+
request.state.role = principal.role
|
| 218 |
+
|
| 219 |
+
|
| 220 |
async def auth_middleware(request: Request, call_next):
|
| 221 |
"""FastAPI ASGI middleware β reject unauthenticated requests."""
|
| 222 |
if AUTH_DISABLED:
|
| 223 |
+
# Dev bypass runs as the legacy admin principal so org-scoped
|
| 224 |
+
# routes (request.state.org_id / require_role) keep working.
|
| 225 |
+
_set_principal(request, Principal(
|
| 226 |
+
user=AUTH_USER, user_id=None,
|
| 227 |
+
org_id=LEGACY_ORG_ID, role=LEGACY_ROLE,
|
| 228 |
+
))
|
| 229 |
return await call_next(request)
|
| 230 |
if is_public(request.url.path, request.method):
|
| 231 |
return await call_next(request)
|
|
|
|
| 238 |
headers={"WWW-Authenticate": "Bearer"},
|
| 239 |
)
|
| 240 |
token = auth_hdr.split(" ", 1)[1].strip()
|
| 241 |
+
principal = verify_token(token)
|
| 242 |
+
if not principal:
|
| 243 |
from fastapi.responses import JSONResponse
|
| 244 |
return JSONResponse(
|
| 245 |
{"detail": "invalid or expired token"}, status_code=401,
|
| 246 |
headers={"WWW-Authenticate": "Bearer"},
|
| 247 |
)
|
| 248 |
+
# Pass identity through so downstream routes can read it
|
| 249 |
+
_set_principal(request, principal)
|
| 250 |
return await call_next(request)
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
# ββ Role gating β FastAPI dependency factory βββββββββββββββββββββββββββββββ
|
| 254 |
+
|
| 255 |
+
def require_role(*roles: str):
|
| 256 |
+
"""Dependency factory: require the verified session role to be in `roles`.
|
| 257 |
+
|
| 258 |
+
Usage:
|
| 259 |
+
@router.post("/thing", dependencies=[Depends(require_role("admin"))])
|
| 260 |
+
# or, to read the role:
|
| 261 |
+
def handler(role: str = Depends(require_role("admin", "sme"))): ...
|
| 262 |
+
|
| 263 |
+
Roles are matched exactly (no implicit hierarchy) β pass every role that
|
| 264 |
+
may call the route. With no arguments it only requires authentication.
|
| 265 |
+
Legacy tokens and the BU_AUTH_DISABLED=1 dev bypass carry role 'admin'.
|
| 266 |
+
"""
|
| 267 |
+
|
| 268 |
+
def _dependency(request: Request) -> str:
|
| 269 |
+
role = getattr(request.state, "role", None)
|
| 270 |
+
if role is None:
|
| 271 |
+
# Public/unauthenticated path β the middleware never ran a
|
| 272 |
+
# verification for this request.
|
| 273 |
+
raise HTTPException(
|
| 274 |
+
status_code=401, detail="authentication required",
|
| 275 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 276 |
+
)
|
| 277 |
+
if roles and role not in roles:
|
| 278 |
+
raise HTTPException(status_code=403, detail="insufficient role")
|
| 279 |
+
return role
|
| 280 |
+
|
| 281 |
+
return _dependency
|
api/billing.py
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Billing + marketplace licensing API β docs/HARDENING.md Phase 4.
|
| 3 |
+
|
| 4 |
+
Two routers, both mounted by api/server.py:
|
| 5 |
+
|
| 6 |
+
router β /billing/* license admin (org-scoped, T1 per
|
| 7 |
+
docs/TENANCY.md) + optional Stripe self-serve checkout
|
| 8 |
+
experts_router β /experts/{agent_id}/chat license-key-gated delivery
|
| 9 |
+
proxy (metered per call)
|
| 10 |
+
|
| 11 |
+
Stripe is OPTIONAL by design. Without STRIPE_API_KEY the platform still
|
| 12 |
+
issues 'manual' licenses via POST /billing/licenses (design partners pay by
|
| 13 |
+
invoice) and all gating / metering / revocation behaves identically; the
|
| 14 |
+
Stripe endpoints (/billing/checkout, /billing/webhook) then 404 cleanly.
|
| 15 |
+
With STRIPE_API_KEY (+ STRIPE_WEBHOOK_SECRET for the webhook) Stripe adds
|
| 16 |
+
self-serve subscription checkout on top: checkout.session.completed issues
|
| 17 |
+
the license, customer.subscription.deleted revokes it.
|
| 18 |
+
|
| 19 |
+
Key handling (atp/licensing.py stores only the key_id half β never the
|
| 20 |
+
secret):
|
| 21 |
+
* The plaintext licenseKey is returned exactly ONCE β in the POST
|
| 22 |
+
/billing/licenses response. Save it then or re-issue.
|
| 23 |
+
* Webhook-issued keys are DISCARDED here after issuance (the webhook
|
| 24 |
+
response goes to Stripe, not the customer) β delivery happens
|
| 25 |
+
out-of-band via the billing portal / support until a customer portal
|
| 26 |
+
ships.
|
| 27 |
+
* GET /billing/licenses never returns key material (keyId, the public
|
| 28 |
+
identifier half, is not key material).
|
| 29 |
+
|
| 30 |
+
Enforcement model for /experts/{agent_id}/chat: the route is on the
|
| 31 |
+
auth-middleware allowlist (bearer optional) but the handler calls
|
| 32 |
+
atp.licensing.check_license on EVERY request β a DB status read per call is
|
| 33 |
+
the enforcement, there is deliberately NO cache of check results, so
|
| 34 |
+
revoking a license cuts this path off on the very next call.
|
| 35 |
+
|
| 36 |
+
Org scoping: /billing/* admin routes take the org ONLY from verified
|
| 37 |
+
request.state (set by the auth middleware from the JWT β TENANCY.md layer 1).
|
| 38 |
+
The expert gate authenticates by license key instead; the license ROW
|
| 39 |
+
carries the org its usage is metered under.
|
| 40 |
+
"""
|
| 41 |
+
|
| 42 |
+
from __future__ import annotations
|
| 43 |
+
|
| 44 |
+
import os
|
| 45 |
+
import re
|
| 46 |
+
import sys
|
| 47 |
+
|
| 48 |
+
from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
| 49 |
+
from pydantic import BaseModel
|
| 50 |
+
|
| 51 |
+
from api.auth import require_role
|
| 52 |
+
from atp import licensing, store
|
| 53 |
+
|
| 54 |
+
router = APIRouter(prefix="/billing")
|
| 55 |
+
experts_router = APIRouter(prefix="/experts")
|
| 56 |
+
|
| 57 |
+
#: Backend spec for the expert delivery proxy (agents/backend.get_backend).
|
| 58 |
+
#: The dry-run default keeps the delivery path CI-exercisable with no keys.
|
| 59 |
+
EXPERT_SPEC_ENV = "ATP_EXPERT_SPEC"
|
| 60 |
+
EXPERT_SPEC_DEFAULT = "dryrun:candidate"
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
# ββ Tenancy helper (same idiom as api/atp.py) βββββββββββββββββββββββββββββββ
|
| 64 |
+
|
| 65 |
+
def _org(request: Request) -> str:
|
| 66 |
+
"""Org for this request β ONLY from verified auth state (TENANCY.md Β§4
|
| 67 |
+
layer 1). 'org-demo' when auth is disabled / legacy single-admin token."""
|
| 68 |
+
return getattr(request.state, "org_id", None) or "org-demo"
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
# ββ Seed catalog helpers (T0 β atp/store.get_data) βββββββββββββββββββββββββ
|
| 72 |
+
|
| 73 |
+
def _agent(agent_id: str) -> dict | None:
|
| 74 |
+
for a in store.get_data().get("AGENTS", []):
|
| 75 |
+
if a.get("id") == agent_id:
|
| 76 |
+
return a
|
| 77 |
+
return None
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _top_cert_label(agent: dict) -> str:
|
| 81 |
+
"""Label of the agent's highest-layer cert (Stripe product naming)."""
|
| 82 |
+
certs = {c.get("id"): c for c in store.get_data().get("CERTS", [])}
|
| 83 |
+
held = [certs[cid] for cid in (agent.get("certIds") or []) if cid in certs]
|
| 84 |
+
if not held:
|
| 85 |
+
return ""
|
| 86 |
+
top = max(held, key=lambda c: c.get("layer") or 0)
|
| 87 |
+
return str(top.get("label") or "")
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _unit_amount_usd(price: str | None) -> int | None:
|
| 91 |
+
"""Seed licensing.price ('$4,800/mo') β Stripe unit_amount cents."""
|
| 92 |
+
m = re.search(r"\$?\s*([0-9][0-9,]*(?:\.[0-9]+)?)", price or "")
|
| 93 |
+
if not m:
|
| 94 |
+
return None
|
| 95 |
+
return int(round(float(m.group(1).replace(",", "")) * 100))
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _license_for_subscription(org_id: str,
|
| 99 |
+
subscription_id: str | None) -> dict | None:
|
| 100 |
+
"""The org's license bound to a Stripe subscription id, if any."""
|
| 101 |
+
if not subscription_id:
|
| 102 |
+
return None
|
| 103 |
+
for row in licensing.list_licenses(org_id):
|
| 104 |
+
if row.get("stripeSubscription") == subscription_id:
|
| 105 |
+
return row
|
| 106 |
+
return None
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
# ββ Stripe (optional β endpoints 404 when unconfigured) ββββββββββββββββββββ
|
| 110 |
+
|
| 111 |
+
def _stripe_key() -> str | None:
|
| 112 |
+
return os.environ.get("STRIPE_API_KEY") or None
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _stripe_configured_or_404():
|
| 116 |
+
"""Stripe endpoints don't exist without config β plain 404, no oracle."""
|
| 117 |
+
if not _stripe_key():
|
| 118 |
+
raise HTTPException(404, "Not Found")
|
| 119 |
+
try:
|
| 120 |
+
import stripe
|
| 121 |
+
except ImportError as e: # pragma: no cover β requirements.txt has it
|
| 122 |
+
raise HTTPException(
|
| 123 |
+
503, "stripe library not installed (pip install stripe)") from e
|
| 124 |
+
stripe.api_key = _stripe_key()
|
| 125 |
+
return stripe
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def _cancel_subscription_best_effort(subscription_id: str | None) -> bool:
|
| 129 |
+
"""Cancel the Stripe subscription behind a revoked license. Best-effort
|
| 130 |
+
by contract: the license is already revoked in OUR ledger (the expert
|
| 131 |
+
gate is cut off regardless); a Stripe hiccup must not un-revoke it."""
|
| 132 |
+
if not subscription_id or not _stripe_key():
|
| 133 |
+
return False
|
| 134 |
+
try:
|
| 135 |
+
import stripe
|
| 136 |
+
stripe.api_key = _stripe_key()
|
| 137 |
+
stripe.Subscription.cancel(subscription_id)
|
| 138 |
+
return True
|
| 139 |
+
except Exception as e: # noqa: BLE001 β best-effort by contract
|
| 140 |
+
print(f"WARNING: stripe cancel of {subscription_id} failed: {e}",
|
| 141 |
+
file=sys.stderr)
|
| 142 |
+
return False
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
# ββ License admin routes ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 146 |
+
|
| 147 |
+
class IssueBody(BaseModel):
|
| 148 |
+
agentId: str
|
| 149 |
+
kind: str = "production" # licensing.VALID_KINDS
|
| 150 |
+
expiresAt: str | None = None # ISO-8601 UTC; None = perpetual
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
@router.post("/licenses", dependencies=[Depends(require_role("admin"))])
|
| 154 |
+
def billing_issue(body: IssueBody, request: Request):
|
| 155 |
+
"""Issue a 'manual' license for a marketplace agent (role admin).
|
| 156 |
+
|
| 157 |
+
The Stripe-less path (design partners pay by invoice). Returns
|
| 158 |
+
{license, licenseKey}; the plaintext licenseKey appears ONLY here β
|
| 159 |
+
the engine stores just its key_id half.
|
| 160 |
+
"""
|
| 161 |
+
try:
|
| 162 |
+
return licensing.issue_license(_org(request), body.agentId, body.kind,
|
| 163 |
+
expires_at=body.expiresAt)
|
| 164 |
+
except licensing.UnknownAgentError as e:
|
| 165 |
+
raise HTTPException(404, str(e)) from e
|
| 166 |
+
except licensing.AgentNotLicensableError as e:
|
| 167 |
+
raise HTTPException(400, str(e)) from e
|
| 168 |
+
except ValueError as e: # bad kind / expires_at
|
| 169 |
+
raise HTTPException(400, str(e)) from e
|
| 170 |
+
except licensing.LicenseSigningKeyError as e:
|
| 171 |
+
raise HTTPException(503, str(e)) from e
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
@router.post("/licenses/{license_id}/reissue",
|
| 175 |
+
dependencies=[Depends(require_role("admin"))])
|
| 176 |
+
def billing_reissue(license_id: str, request: Request):
|
| 177 |
+
"""Mint a fresh key for an active license (role admin). The previous key
|
| 178 |
+
stops working immediately. This is how Stripe self-serve customers get
|
| 179 |
+
their key: the webhook stores no plaintext, an org admin re-issues here
|
| 180 |
+
and delivers it. Key shown ONCE."""
|
| 181 |
+
try:
|
| 182 |
+
return licensing.reissue_key(_org(request), license_id)
|
| 183 |
+
except licensing.LicenseNotFoundError as e:
|
| 184 |
+
raise HTTPException(404, str(e)) from e
|
| 185 |
+
except ValueError as e:
|
| 186 |
+
raise HTTPException(400, str(e)) from e
|
| 187 |
+
except licensing.LicenseSigningKeyError as e:
|
| 188 |
+
raise HTTPException(503, str(e)) from e
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
@router.get("/licenses",
|
| 192 |
+
dependencies=[Depends(require_role("admin", "viewer"))])
|
| 193 |
+
def billing_licenses(request: Request):
|
| 194 |
+
"""Org's licenses, latest-first. Never contains key material β keys are
|
| 195 |
+
shown once, at issue time (keyId is the public identifier half only)."""
|
| 196 |
+
return {"licenses": licensing.list_licenses(_org(request))}
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
class RevokeBody(BaseModel):
|
| 200 |
+
reason: str = ""
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
@router.post("/licenses/{license_id}/revoke",
|
| 204 |
+
dependencies=[Depends(require_role("admin"))])
|
| 205 |
+
def billing_revoke(license_id: str, body: RevokeBody, request: Request):
|
| 206 |
+
"""Revoke a license (role admin, idempotent). Takes effect on the very
|
| 207 |
+
next /experts call β the gate re-reads DB state every time. Cross-tenant
|
| 208 |
+
ids 404 identically to unknown ids (TENANCY.md #6). When the license was
|
| 209 |
+
Stripe-issued AND Stripe is configured, the subscription is cancelled
|
| 210 |
+
too, best-effort."""
|
| 211 |
+
try:
|
| 212 |
+
revoked = licensing.revoke_license(
|
| 213 |
+
_org(request), license_id, body.reason.strip() or "revoked by admin")
|
| 214 |
+
except licensing.LicenseNotFoundError as e:
|
| 215 |
+
raise HTTPException(404, f"license {license_id} not found") from e
|
| 216 |
+
revoked["stripeCancelled"] = _cancel_subscription_best_effort(
|
| 217 |
+
revoked.get("stripeSubscription"))
|
| 218 |
+
return revoked
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
@router.get("/usage", dependencies=[Depends(require_role("admin", "viewer"))])
|
| 222 |
+
def billing_usage(request: Request, licenseId: str | None = None):
|
| 223 |
+
"""Metered usage for the org (optionally one license):
|
| 224 |
+
{calls, tokensIn, tokensOut, byAgent}."""
|
| 225 |
+
return licensing.usage_summary(_org(request), license_id=licenseId)
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
# ββ Stripe self-serve checkout ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 229 |
+
|
| 230 |
+
class CheckoutBody(BaseModel):
|
| 231 |
+
agentId: str
|
| 232 |
+
kind: str = "production"
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
@router.post("/checkout", dependencies=[Depends(require_role("admin"))])
|
| 236 |
+
def billing_checkout(body: CheckoutBody, request: Request):
|
| 237 |
+
"""Create a Stripe subscription Checkout Session for a seed listing.
|
| 238 |
+
|
| 239 |
+
404 when STRIPE_API_KEY is unset (manual licensing still fully works).
|
| 240 |
+
Price comes from the seed agent's licensing.price; the license itself is
|
| 241 |
+
issued by the checkout.session.completed webhook, keyed by the metadata
|
| 242 |
+
written here ({org_id, agent_id, kind} β org from verified state ONLY,
|
| 243 |
+
so a tampered client cannot buy a license into another org).
|
| 244 |
+
"""
|
| 245 |
+
stripe = _stripe_configured_or_404()
|
| 246 |
+
org = _org(request)
|
| 247 |
+
agent = _agent(body.agentId)
|
| 248 |
+
if agent is None:
|
| 249 |
+
raise HTTPException(404, f"agent {body.agentId} not found")
|
| 250 |
+
if body.kind not in licensing.VALID_KINDS:
|
| 251 |
+
raise HTTPException(
|
| 252 |
+
400, f"kind must be one of {sorted(licensing.VALID_KINDS)}")
|
| 253 |
+
seed_licensing = agent.get("licensing") or {}
|
| 254 |
+
if not seed_licensing.get("available"):
|
| 255 |
+
raise HTTPException(400, f"agent {body.agentId} is not licensable")
|
| 256 |
+
unit_amount = _unit_amount_usd(seed_licensing.get("price"))
|
| 257 |
+
if not unit_amount:
|
| 258 |
+
raise HTTPException(
|
| 259 |
+
400, f"agent {body.agentId} has no parseable price")
|
| 260 |
+
|
| 261 |
+
label = _top_cert_label(agent)
|
| 262 |
+
product_name = agent.get("name") or body.agentId
|
| 263 |
+
if label:
|
| 264 |
+
product_name = f"{product_name} β {label}"
|
| 265 |
+
metadata = {"org_id": org, "agent_id": body.agentId, "kind": body.kind}
|
| 266 |
+
base = (os.environ.get("BU_PUBLIC_URL")
|
| 267 |
+
or request.headers.get("origin")
|
| 268 |
+
or str(request.base_url)).rstrip("/")
|
| 269 |
+
try:
|
| 270 |
+
session = stripe.checkout.Session.create(
|
| 271 |
+
mode="subscription",
|
| 272 |
+
line_items=[{
|
| 273 |
+
"quantity": 1,
|
| 274 |
+
"price_data": {
|
| 275 |
+
"currency": "usd",
|
| 276 |
+
"unit_amount": unit_amount,
|
| 277 |
+
"recurring": {"interval": "month"},
|
| 278 |
+
"product_data": {"name": product_name},
|
| 279 |
+
},
|
| 280 |
+
}],
|
| 281 |
+
# Session metadata drives checkout.session.completed issuance;
|
| 282 |
+
# subscription_data.metadata puts the SAME keys on the
|
| 283 |
+
# subscription object so customer.subscription.deleted can
|
| 284 |
+
# resolve the org + license to revoke.
|
| 285 |
+
metadata=metadata,
|
| 286 |
+
subscription_data={"metadata": metadata},
|
| 287 |
+
success_url=(base +
|
| 288 |
+
"/#/billing/success?session_id={CHECKOUT_SESSION_ID}"),
|
| 289 |
+
cancel_url=base + "/#/billing/cancelled",
|
| 290 |
+
)
|
| 291 |
+
except Exception as e: # noqa: BLE001 β stripe SDK error zoo
|
| 292 |
+
raise HTTPException(502, f"stripe checkout failed: {e}") from e
|
| 293 |
+
return {"url": session.url, "sessionId": session.id}
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
# ββ Stripe webhook (public path β the signature IS the authentication) βββββ
|
| 297 |
+
|
| 298 |
+
@router.post("/webhook")
|
| 299 |
+
async def billing_webhook(request: Request):
|
| 300 |
+
"""Stripe event sink. api/server.py adds this exact path to the auth
|
| 301 |
+
allowlist: Stripe cannot send a bearer β every delivery is authenticated
|
| 302 |
+
by its Stripe-Signature header, verified against STRIPE_WEBHOOK_SECRET
|
| 303 |
+
(invalid/missing signature β 400; Stripe unconfigured β 404).
|
| 304 |
+
|
| 305 |
+
Handled events (anything else is acknowledged and ignored):
|
| 306 |
+
checkout.session.completed β issue the license from the session
|
| 307 |
+
metadata ({org_id, agent_id, kind} written by /billing/checkout),
|
| 308 |
+
storing the Stripe customer/subscription ids. Idempotent across
|
| 309 |
+
Stripe's retries (one license per subscription). The plaintext key
|
| 310 |
+
is DISCARDED here β only its key_id persists, so it is not
|
| 311 |
+
retrievable later (out-of-band delivery via the billing portal).
|
| 312 |
+
customer.subscription.deleted β revoke the matching license
|
| 313 |
+
(reason 'subscription cancelled').
|
| 314 |
+
|
| 315 |
+
Responds 200 fast β handling is local DB writes only, no network calls.
|
| 316 |
+
"""
|
| 317 |
+
if not _stripe_key() or not os.environ.get("STRIPE_WEBHOOK_SECRET"):
|
| 318 |
+
raise HTTPException(404, "Not Found")
|
| 319 |
+
import stripe
|
| 320 |
+
|
| 321 |
+
payload = await request.body()
|
| 322 |
+
sig_header = request.headers.get("stripe-signature", "")
|
| 323 |
+
try:
|
| 324 |
+
stripe.Webhook.construct_event(
|
| 325 |
+
payload, sig_header, os.environ["STRIPE_WEBHOOK_SECRET"])
|
| 326 |
+
except Exception as e: # noqa: BLE001 β bad payload OR bad signature
|
| 327 |
+
raise HTTPException(400, "invalid webhook signature") from e
|
| 328 |
+
|
| 329 |
+
# construct_event verified signature + JSON; work on the verified bytes
|
| 330 |
+
# as plain dicts (StripeObject's dict-likeness varies across versions).
|
| 331 |
+
import json
|
| 332 |
+
event = json.loads(payload)
|
| 333 |
+
etype = event.get("type", "")
|
| 334 |
+
obj = (event.get("data") or {}).get("object") or {}
|
| 335 |
+
|
| 336 |
+
if etype == "checkout.session.completed":
|
| 337 |
+
md = obj.get("metadata") or {}
|
| 338 |
+
org, agent_id = md.get("org_id"), md.get("agent_id")
|
| 339 |
+
kind = md.get("kind") or "production"
|
| 340 |
+
if not (org and agent_id):
|
| 341 |
+
return {"received": True, "handled": etype,
|
| 342 |
+
"skipped": "missing metadata"}
|
| 343 |
+
subscription = obj.get("subscription")
|
| 344 |
+
# Stripe retries deliveries β never double-issue for one sub.
|
| 345 |
+
if subscription and _license_for_subscription(org, subscription):
|
| 346 |
+
return {"received": True, "handled": etype,
|
| 347 |
+
"skipped": "already issued"}
|
| 348 |
+
try:
|
| 349 |
+
issued = licensing.issue_license(
|
| 350 |
+
org, agent_id, kind,
|
| 351 |
+
stripe_customer=obj.get("customer"),
|
| 352 |
+
stripe_subscription=subscription)
|
| 353 |
+
except (licensing.LicensingError, ValueError) as e:
|
| 354 |
+
# Ack (200) so Stripe stops retrying a permanently-bad event,
|
| 355 |
+
# but say what was wrong for the webhook log.
|
| 356 |
+
print(f"WARNING: webhook license issue failed: {e}",
|
| 357 |
+
file=sys.stderr)
|
| 358 |
+
return {"received": True, "handled": etype, "skipped": str(e)}
|
| 359 |
+
# Hash-only persistence: the plaintext key never leaves this scope.
|
| 360 |
+
return {"received": True, "handled": etype,
|
| 361 |
+
"licenseId": issued["license"]["id"]}
|
| 362 |
+
|
| 363 |
+
if etype == "customer.subscription.deleted":
|
| 364 |
+
md = obj.get("metadata") or {}
|
| 365 |
+
org, sub_id = md.get("org_id"), obj.get("id")
|
| 366 |
+
if not (org and sub_id):
|
| 367 |
+
return {"received": True, "handled": etype,
|
| 368 |
+
"skipped": "missing metadata"}
|
| 369 |
+
row = _license_for_subscription(org, sub_id)
|
| 370 |
+
if row is None:
|
| 371 |
+
return {"received": True, "handled": etype,
|
| 372 |
+
"skipped": "no matching license"}
|
| 373 |
+
licensing.revoke_license(org, row["id"], "subscription cancelled")
|
| 374 |
+
return {"received": True, "handled": etype, "licenseId": row["id"]}
|
| 375 |
+
|
| 376 |
+
return {"received": True, "ignored": etype}
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
# ββ Expert delivery gate β /experts/{agent_id}/chat βββββββββββββββββββββββββ
|
| 380 |
+
|
| 381 |
+
class ChatBody(BaseModel):
|
| 382 |
+
messages: list[dict]
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
def _persona(agent: dict) -> str:
|
| 386 |
+
"""Agent persona system prompt from T0 seed fields only (no tenant
|
| 387 |
+
data β TENANCY.md leak surface #5)."""
|
| 388 |
+
name = agent.get("name") or agent.get("id")
|
| 389 |
+
level = agent.get("level")
|
| 390 |
+
domains = ", ".join(agent.get("domains") or []) or "general"
|
| 391 |
+
skills = ", ".join(
|
| 392 |
+
s.get("name", "") for s in (agent.get("skills") or [])[:8])
|
| 393 |
+
summary = (agent.get("reportCard") or {}).get("summary", "")
|
| 394 |
+
boundaries = " ".join(
|
| 395 |
+
f.get("boundary", "") for f in (agent.get("failures") or [])[:2])
|
| 396 |
+
parts = [
|
| 397 |
+
f"You are {name}, an ATP Level {level} certified expert agent "
|
| 398 |
+
f"(domains: {domains}).",
|
| 399 |
+
f"Certified skills: {skills}." if skills else "",
|
| 400 |
+
summary,
|
| 401 |
+
f"Known boundaries: {boundaries}" if boundaries else "",
|
| 402 |
+
"Answer within your certified scope; when a request falls outside "
|
| 403 |
+
"it, say so plainly and recommend human review.",
|
| 404 |
+
]
|
| 405 |
+
return " ".join(p for p in parts if p)
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
def _flatten_messages(messages: list[dict]) -> str:
|
| 409 |
+
"""[{role, content}, ...] β one transcript string (backends take a
|
| 410 |
+
single system + user pair, agents/backend.Backend.complete)."""
|
| 411 |
+
parts = []
|
| 412 |
+
for m in messages:
|
| 413 |
+
if not isinstance(m, dict):
|
| 414 |
+
continue
|
| 415 |
+
role = str(m.get("role", "user")).strip().lower() or "user"
|
| 416 |
+
content = str(m.get("content", "")).strip()
|
| 417 |
+
if content:
|
| 418 |
+
parts.append(f"{role.capitalize()}: {content}")
|
| 419 |
+
return "\n\n".join(parts)
|
| 420 |
+
|
| 421 |
+
|
| 422 |
+
def _license_org(license_key: str) -> str | None:
|
| 423 |
+
"""Org the checked license row belongs to.
|
| 424 |
+
|
| 425 |
+
check_license()'s public license dict intentionally carries no org key
|
| 426 |
+
(atp/store.py convention), but metering must land under the license
|
| 427 |
+
ROW's org (T1) β never under a caller-supplied value, and the keyed
|
| 428 |
+
machine caller has no bearer/org at all. Resolve it through the same
|
| 429 |
+
key-id lookup check_license used; the in-package coupling to
|
| 430 |
+
licensing's _parse_key/_load_by_key_id is deliberate β the alternative
|
| 431 |
+
(raw licenses SQL here) would break the DAL-only convention
|
| 432 |
+
(TENANCY.md layer 2).
|
| 433 |
+
"""
|
| 434 |
+
parsed = licensing._parse_key(license_key)
|
| 435 |
+
if not parsed:
|
| 436 |
+
return None
|
| 437 |
+
row = licensing._load_by_key_id(parsed[0])
|
| 438 |
+
return (row or {}).get("org_id")
|
| 439 |
+
|
| 440 |
+
|
| 441 |
+
_DENIED_403 = ("revoked", "expired")
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
@experts_router.post("/{agent_id}/chat")
|
| 445 |
+
def expert_chat(
|
| 446 |
+
agent_id: str,
|
| 447 |
+
body: ChatBody,
|
| 448 |
+
request: Request,
|
| 449 |
+
license_key: str | None = Header(default=None, alias="X-ATP-License-Key"),
|
| 450 |
+
):
|
| 451 |
+
"""Licensed expert delivery (HARDENING.md Phase 4).
|
| 452 |
+
|
| 453 |
+
Authentication is the LICENSE KEY (X-ATP-License-Key header), not a
|
| 454 |
+
bearer β api/server.py puts '/experts/' on the middleware allowlist and
|
| 455 |
+
THIS handler enforces instead: atp.licensing.check_license runs on EVERY
|
| 456 |
+
request (a DB status read β deliberately uncached, so revocation cuts
|
| 457 |
+
the path off on the very next call). 401 invalid key / 403 revoked or
|
| 458 |
+
expired (with the reason). Every successful call appends a usage_events
|
| 459 |
+
row (metering).
|
| 460 |
+
"""
|
| 461 |
+
if not license_key or not license_key.strip():
|
| 462 |
+
raise HTTPException(
|
| 463 |
+
401, "missing license key (X-ATP-License-Key header)")
|
| 464 |
+
license_key = license_key.strip()
|
| 465 |
+
|
| 466 |
+
try:
|
| 467 |
+
check = licensing.check_license(license_key)
|
| 468 |
+
except licensing.LicenseSigningKeyError as e:
|
| 469 |
+
raise HTTPException(503, str(e)) from e
|
| 470 |
+
if not check.get("ok"):
|
| 471 |
+
reason = str(check.get("reason") or "invalid")
|
| 472 |
+
if reason in _DENIED_403:
|
| 473 |
+
raise HTTPException(403, f"license {reason}")
|
| 474 |
+
raise HTTPException(401, f"invalid license key ({reason})")
|
| 475 |
+
lic = check.get("license") or {}
|
| 476 |
+
|
| 477 |
+
if lic.get("agentId") != agent_id:
|
| 478 |
+
raise HTTPException(
|
| 479 |
+
403, f"license is for agent {lic.get('agentId')}, not {agent_id}")
|
| 480 |
+
|
| 481 |
+
agent = _agent(agent_id)
|
| 482 |
+
if agent is None: # licensed against a since-removed seed agent
|
| 483 |
+
raise HTTPException(404, f"agent {agent_id} not found")
|
| 484 |
+
|
| 485 |
+
transcript = _flatten_messages(body.messages or [])
|
| 486 |
+
if not transcript:
|
| 487 |
+
raise HTTPException(400, "messages must contain at least one "
|
| 488 |
+
"non-empty {role, content} entry")
|
| 489 |
+
|
| 490 |
+
from agents.backend import get_backend
|
| 491 |
+
spec = os.environ.get(EXPERT_SPEC_ENV, EXPERT_SPEC_DEFAULT)
|
| 492 |
+
system = _persona(agent)
|
| 493 |
+
try:
|
| 494 |
+
reply = get_backend(spec).complete(system, transcript)
|
| 495 |
+
except Exception as e: # noqa: BLE001 β backend/network failures
|
| 496 |
+
raise HTTPException(503, f"expert backend unavailable: {e}") from e
|
| 497 |
+
|
| 498 |
+
# Metering β approx token counts (chars/4). No usage row, no reply:
|
| 499 |
+
# usage_events is the append-only billing record (HARDENING.md Phase 4).
|
| 500 |
+
org = _license_org(license_key) or "org-demo"
|
| 501 |
+
try:
|
| 502 |
+
usage = licensing.record_usage(
|
| 503 |
+
org, lic.get("id"), agent_id,
|
| 504 |
+
endpoint=f"/experts/{agent_id}/chat",
|
| 505 |
+
tokens_in=max(1, (len(system) + len(transcript)) // 4),
|
| 506 |
+
tokens_out=max(1, len(reply or "") // 4),
|
| 507 |
+
status="ok",
|
| 508 |
+
)
|
| 509 |
+
except Exception as e: # noqa: BLE001
|
| 510 |
+
raise HTTPException(503, f"usage metering failed: {e}") from e
|
| 511 |
+
|
| 512 |
+
return {"reply": reply, "usage": usage}
|
api/identity.py
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Org-scoped identity for Brain University β Phase 2 (docs/TENANCY.md).
|
| 3 |
+
|
| 4 |
+
Three ways to obtain a session:
|
| 5 |
+
1. POST /auth/login {email, password} β local accounts (argon2id)
|
| 6 |
+
2. GET /auth/oidc/login β GET /auth/oidc/callback β OIDC authorization-code
|
| 7 |
+
flow (issuer/client from env; endpoints 404 when OIDC_ISSUER is unset)
|
| 8 |
+
3. Legacy POST /login (api/server.py) β single-admin HMAC token;
|
| 9 |
+
api.auth.verify_token() maps it to org 'org-demo' with role 'admin', so
|
| 10 |
+
the deployed demo keeps working with zero new env.
|
| 11 |
+
|
| 12 |
+
Session = JWT (HS256 over BU_AUTH_SECRET), claims {sub, org_id, role, exp}.
|
| 13 |
+
`sub` is users.user_id (the identity spec's `id` β see migrations/004).
|
| 14 |
+
|
| 15 |
+
Env (all optional; partial sets fail loudly, P0 style):
|
| 16 |
+
BU_BOOTSTRAP_ADMIN_EMAIL β with _PASSWORD: provision this local admin in
|
| 17 |
+
BU_BOOTSTRAP_ADMIN_PASSWORD org 'org-demo' on first use if absent.
|
| 18 |
+
One without the other = RuntimeError.
|
| 19 |
+
OIDC_ISSUER β enables /auth/oidc/*; discovery at
|
| 20 |
+
{OIDC_ISSUER}/.well-known/openid-configuration
|
| 21 |
+
OIDC_CLIENT_ID β REQUIRED once OIDC_ISSUER is set
|
| 22 |
+
OIDC_CLIENT_SECRET β REQUIRED once OIDC_ISSUER is set
|
| 23 |
+
OIDC_REDIRECT_URL β REQUIRED once OIDC_ISSUER is set; must match
|
| 24 |
+
the IdP-registered callback
|
| 25 |
+
(https://host/auth/oidc/callback)
|
| 26 |
+
|
| 27 |
+
OIDC org resolution: user linked by (iss, sub); on first login the verified
|
| 28 |
+
email's domain must match an orgs.domain row, else 403 'no org for domain'.
|
| 29 |
+
OIDC users default to role 'viewer'.
|
| 30 |
+
|
| 31 |
+
Mounted by api/server.py:
|
| 32 |
+
from api.identity import router as identity_router
|
| 33 |
+
app.include_router(identity_router)
|
| 34 |
+
"""
|
| 35 |
+
|
| 36 |
+
from __future__ import annotations
|
| 37 |
+
|
| 38 |
+
import os
|
| 39 |
+
import secrets
|
| 40 |
+
import threading
|
| 41 |
+
import time
|
| 42 |
+
from urllib.parse import urlencode
|
| 43 |
+
|
| 44 |
+
import httpx
|
| 45 |
+
import jwt
|
| 46 |
+
from argon2 import PasswordHasher
|
| 47 |
+
from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError
|
| 48 |
+
from fastapi import APIRouter, HTTPException
|
| 49 |
+
from fastapi.responses import RedirectResponse
|
| 50 |
+
from pydantic import BaseModel
|
| 51 |
+
|
| 52 |
+
from api import auth as _auth # BU_AUTH_SECRET / TTL; auth does NOT import us at module level
|
| 53 |
+
|
| 54 |
+
BOOTSTRAP_ORG_ID = "org-demo" # seeded by migrations/004
|
| 55 |
+
ROLES = ("admin", "sme", "viewer")
|
| 56 |
+
|
| 57 |
+
SESSION_ALGO = "HS256"
|
| 58 |
+
STATE_TTL_S = 600 # OIDC state/nonce validity: 10 minutes
|
| 59 |
+
_STATE_AUD = "bu-oidc-state" # aud claim keeps state JWTs unusable as sessions
|
| 60 |
+
_ID_TOKEN_ALGOS = ["RS256", "RS384", "RS512", "ES256", "ES384", "PS256"]
|
| 61 |
+
|
| 62 |
+
# ββ Env (validated at import β P0 fail-loud style) ββββββββββββββββββββββββββ
|
| 63 |
+
|
| 64 |
+
OIDC_ISSUER = os.environ.get("OIDC_ISSUER", "").strip()
|
| 65 |
+
OIDC_CLIENT_ID = os.environ.get("OIDC_CLIENT_ID", "").strip()
|
| 66 |
+
OIDC_CLIENT_SECRET = os.environ.get("OIDC_CLIENT_SECRET", "")
|
| 67 |
+
OIDC_REDIRECT_URL = os.environ.get("OIDC_REDIRECT_URL", "").strip()
|
| 68 |
+
|
| 69 |
+
if OIDC_ISSUER:
|
| 70 |
+
_missing = [name for name, val in (
|
| 71 |
+
("OIDC_CLIENT_ID", OIDC_CLIENT_ID),
|
| 72 |
+
("OIDC_CLIENT_SECRET", OIDC_CLIENT_SECRET),
|
| 73 |
+
("OIDC_REDIRECT_URL", OIDC_REDIRECT_URL),
|
| 74 |
+
) if not val]
|
| 75 |
+
if _missing:
|
| 76 |
+
raise RuntimeError(
|
| 77 |
+
"Refusing to start: OIDC_ISSUER is set but required OIDC env "
|
| 78 |
+
"var(s) unset: " + ", ".join(_missing)
|
| 79 |
+
+ ". Set them all, or unset OIDC_ISSUER to disable OIDC "
|
| 80 |
+
"(local + legacy logins keep working without it)."
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _bootstrap_env() -> tuple[str, str] | None:
|
| 85 |
+
"""(email, password) when both set, None when both unset; partial raises."""
|
| 86 |
+
email = os.environ.get("BU_BOOTSTRAP_ADMIN_EMAIL", "").strip()
|
| 87 |
+
password = os.environ.get("BU_BOOTSTRAP_ADMIN_PASSWORD", "")
|
| 88 |
+
if not email and not password:
|
| 89 |
+
return None
|
| 90 |
+
if not (email and password):
|
| 91 |
+
missing = "BU_BOOTSTRAP_ADMIN_PASSWORD" if email else "BU_BOOTSTRAP_ADMIN_EMAIL"
|
| 92 |
+
raise RuntimeError(
|
| 93 |
+
f"Refusing to start: {missing} unset while its counterpart is "
|
| 94 |
+
"set. Set both BU_BOOTSTRAP_ADMIN_EMAIL and "
|
| 95 |
+
"BU_BOOTSTRAP_ADMIN_PASSWORD, or neither."
|
| 96 |
+
)
|
| 97 |
+
return email.lower(), password
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
_bootstrap_env() # fail loudly at import on partial env
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
# ββ Password hashing (argon2id) βββββββββββββββββββββββββββββββββββββββββββββ
|
| 104 |
+
|
| 105 |
+
_hasher = PasswordHasher() # argon2id, library-default parameters
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def hash_password(password: str) -> str:
|
| 109 |
+
return _hasher.hash(password)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def verify_password(pw_hash: str, password: str) -> bool:
|
| 113 |
+
try:
|
| 114 |
+
return _hasher.verify(pw_hash, password)
|
| 115 |
+
except (VerifyMismatchError, VerificationError, InvalidHashError):
|
| 116 |
+
return False
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
# ββ Session JWTs β claims {sub, org_id, role, exp} ββββββββββββββββββββββββββ
|
| 120 |
+
|
| 121 |
+
def mint_session(user: dict, ttl_s: int | None = None) -> str:
|
| 122 |
+
"""Issue a session JWT for a users row (dict with id/org_id/role)."""
|
| 123 |
+
exp = int(time.time()) + (ttl_s or _auth.AUTH_TTL_S)
|
| 124 |
+
claims = {
|
| 125 |
+
"sub": user["id"],
|
| 126 |
+
"org_id": user["org_id"],
|
| 127 |
+
"role": user["role"],
|
| 128 |
+
"exp": exp,
|
| 129 |
+
}
|
| 130 |
+
return jwt.encode(claims, _auth.AUTH_SECRET, algorithm=SESSION_ALGO)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def verify_session(token: str) -> dict | None:
|
| 134 |
+
"""Return the session claims when `token` is a valid, unexpired org JWT.
|
| 135 |
+
|
| 136 |
+
None for anything else (legacy HMAC tokens, state tokens, garbage) β
|
| 137 |
+
api.auth.verify_token() then falls back to the legacy verifier.
|
| 138 |
+
"""
|
| 139 |
+
if not token or token.count(".") != 2: # fast path: not a JWT at all
|
| 140 |
+
return None
|
| 141 |
+
try:
|
| 142 |
+
claims = jwt.decode(token, _auth.AUTH_SECRET, algorithms=[SESSION_ALGO])
|
| 143 |
+
except jwt.InvalidTokenError:
|
| 144 |
+
return None
|
| 145 |
+
if not all(claims.get(k) for k in ("sub", "org_id", "role")):
|
| 146 |
+
return None
|
| 147 |
+
if claims["role"] not in ROLES:
|
| 148 |
+
return None
|
| 149 |
+
return claims
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
# ββ DB helpers (users/orgs β migrations/004; users.user_id is the id) βββββββ
|
| 153 |
+
|
| 154 |
+
_USER_COLS = ("user_id, display_name, created_at, org_id, email, role, "
|
| 155 |
+
"oidc_iss, oidc_sub, pw_hash")
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def _db():
|
| 159 |
+
from atp import db
|
| 160 |
+
return db
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
_MIGRATED = False
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def _ensure_db() -> None:
|
| 167 |
+
"""Idempotent lazy migration (same pattern as atp/store.py)."""
|
| 168 |
+
global _MIGRATED
|
| 169 |
+
if not _MIGRATED:
|
| 170 |
+
_db().run_migrations()
|
| 171 |
+
_MIGRATED = True
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def _to_user(row: dict) -> dict:
|
| 175 |
+
user = dict(row)
|
| 176 |
+
user["id"] = user.pop("user_id")
|
| 177 |
+
return user
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def get_user_by_email(email: str) -> dict | None:
|
| 181 |
+
rows = _db().query(
|
| 182 |
+
f"SELECT {_USER_COLS} FROM users WHERE email = :email",
|
| 183 |
+
{"email": email.strip().lower()},
|
| 184 |
+
)
|
| 185 |
+
return _to_user(rows[0]) if rows else None
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def get_user_by_oidc(iss: str, sub: str) -> dict | None:
|
| 189 |
+
rows = _db().query(
|
| 190 |
+
f"SELECT {_USER_COLS} FROM users"
|
| 191 |
+
" WHERE oidc_iss = :iss AND oidc_sub = :sub",
|
| 192 |
+
{"iss": iss, "sub": sub},
|
| 193 |
+
)
|
| 194 |
+
return _to_user(rows[0]) if rows else None
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def get_org_by_domain(domain: str) -> dict | None:
|
| 198 |
+
rows = _db().query(
|
| 199 |
+
"SELECT id, name, domain, created_at FROM orgs"
|
| 200 |
+
" WHERE domain IS NOT NULL AND lower(domain) = lower(:domain)",
|
| 201 |
+
{"domain": domain},
|
| 202 |
+
)
|
| 203 |
+
return dict(rows[0]) if rows else None
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def create_user(*, email: str, org_id: str, role: str,
|
| 207 |
+
oidc_iss: str | None = None, oidc_sub: str | None = None,
|
| 208 |
+
pw_hash: str | None = None,
|
| 209 |
+
display_name: str | None = None) -> dict:
|
| 210 |
+
"""Insert an identity user; returns the created (or racing-winner) row."""
|
| 211 |
+
if role not in ROLES:
|
| 212 |
+
raise ValueError(f"role must be one of {ROLES}, got {role!r}")
|
| 213 |
+
email = email.strip().lower()
|
| 214 |
+
try:
|
| 215 |
+
_db().execute(
|
| 216 |
+
"INSERT INTO users (user_id, display_name, created_at, org_id,"
|
| 217 |
+
" email, role, oidc_iss, oidc_sub, pw_hash)"
|
| 218 |
+
" VALUES (:id, :name, :ts, :org, :email, :role, :iss, :sub, :pw)",
|
| 219 |
+
{
|
| 220 |
+
"id": "u-" + secrets.token_hex(8),
|
| 221 |
+
"name": display_name or email,
|
| 222 |
+
"ts": time.time(), # users.created_at is REAL (001)
|
| 223 |
+
"org": org_id,
|
| 224 |
+
"email": email,
|
| 225 |
+
"role": role,
|
| 226 |
+
"iss": oidc_iss,
|
| 227 |
+
"sub": oidc_sub,
|
| 228 |
+
"pw": pw_hash,
|
| 229 |
+
},
|
| 230 |
+
)
|
| 231 |
+
except Exception:
|
| 232 |
+
# Unique-email race: another worker inserted first β use theirs.
|
| 233 |
+
existing = get_user_by_email(email)
|
| 234 |
+
if existing is None:
|
| 235 |
+
raise
|
| 236 |
+
return existing
|
| 237 |
+
user = get_user_by_email(email)
|
| 238 |
+
assert user is not None
|
| 239 |
+
return user
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def link_user_oidc(user_id: str, iss: str, sub: str) -> None:
|
| 243 |
+
_db().execute(
|
| 244 |
+
"UPDATE users SET oidc_iss = :iss, oidc_sub = :sub"
|
| 245 |
+
" WHERE user_id = :id",
|
| 246 |
+
{"iss": iss, "sub": sub, "id": user_id},
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
# ββ Bootstrap admin (local fallback account, org-demo) ββββββββββββββββββββββ
|
| 251 |
+
|
| 252 |
+
def provision_bootstrap_admin() -> dict | None:
|
| 253 |
+
"""Create the BU_BOOTSTRAP_ADMIN_* local admin in org-demo if absent.
|
| 254 |
+
|
| 255 |
+
Returns the user row (existing or created), or None when the env pair is
|
| 256 |
+
unset. Partial env = RuntimeError (P0 fail-loud style). Idempotent.
|
| 257 |
+
"""
|
| 258 |
+
pair = _bootstrap_env()
|
| 259 |
+
if pair is None:
|
| 260 |
+
return None
|
| 261 |
+
email, password = pair
|
| 262 |
+
_ensure_db()
|
| 263 |
+
existing = get_user_by_email(email)
|
| 264 |
+
if existing is not None:
|
| 265 |
+
return existing
|
| 266 |
+
return create_user(
|
| 267 |
+
email=email,
|
| 268 |
+
org_id=BOOTSTRAP_ORG_ID,
|
| 269 |
+
role="admin",
|
| 270 |
+
pw_hash=hash_password(password),
|
| 271 |
+
display_name="Bootstrap admin",
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
_READY = False
|
| 276 |
+
_READY_LOCK = threading.Lock()
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def _ensure_ready() -> None:
|
| 280 |
+
"""Migrations + bootstrap provisioning, once per process, thread-safe."""
|
| 281 |
+
global _READY
|
| 282 |
+
if _READY:
|
| 283 |
+
return
|
| 284 |
+
with _READY_LOCK:
|
| 285 |
+
if _READY:
|
| 286 |
+
return
|
| 287 |
+
_ensure_db()
|
| 288 |
+
provision_bootstrap_admin()
|
| 289 |
+
_READY = True
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
# ββ OIDC β discovery, JWKS, signed state ββββββββββββββββββββββββββββββββββββ
|
| 293 |
+
|
| 294 |
+
_DISCOVERY_TTL_S = 3600
|
| 295 |
+
_discovery_cache: dict = {}
|
| 296 |
+
_jwks_clients: dict[str, jwt.PyJWKClient] = {}
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
def oidc_enabled() -> bool:
|
| 300 |
+
return bool(OIDC_ISSUER)
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
def _discovery() -> dict:
|
| 304 |
+
"""Fetch (and cache) the issuer's openid-configuration."""
|
| 305 |
+
now = time.time()
|
| 306 |
+
if _discovery_cache.get("cfg") and now - _discovery_cache["fetched"] < _DISCOVERY_TTL_S:
|
| 307 |
+
return _discovery_cache["cfg"]
|
| 308 |
+
url = OIDC_ISSUER.rstrip("/") + "/.well-known/openid-configuration"
|
| 309 |
+
try:
|
| 310 |
+
resp = httpx.get(url, timeout=10.0)
|
| 311 |
+
resp.raise_for_status()
|
| 312 |
+
cfg = resp.json()
|
| 313 |
+
except httpx.HTTPError as e:
|
| 314 |
+
raise HTTPException(502, f"OIDC discovery failed: {e.__class__.__name__}") from e
|
| 315 |
+
for key in ("authorization_endpoint", "token_endpoint", "jwks_uri", "issuer"):
|
| 316 |
+
if key not in cfg:
|
| 317 |
+
raise HTTPException(502, f"OIDC discovery document missing {key!r}")
|
| 318 |
+
_discovery_cache.update(cfg=cfg, fetched=now)
|
| 319 |
+
return cfg
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
def _jwks_client(jwks_uri: str) -> jwt.PyJWKClient:
|
| 323 |
+
client = _jwks_clients.get(jwks_uri)
|
| 324 |
+
if client is None:
|
| 325 |
+
client = jwt.PyJWKClient(jwks_uri, cache_keys=True)
|
| 326 |
+
_jwks_clients[jwks_uri] = client
|
| 327 |
+
return client
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
def _mint_state(nonce: str) -> str:
|
| 331 |
+
"""Signed CSRF state, 10 min TTL. aud keeps it unusable as a session."""
|
| 332 |
+
claims = {"aud": _STATE_AUD, "nonce": nonce,
|
| 333 |
+
"exp": int(time.time()) + STATE_TTL_S}
|
| 334 |
+
return jwt.encode(claims, _auth.AUTH_SECRET, algorithm=SESSION_ALGO)
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
def _verify_state(state: str) -> dict | None:
|
| 338 |
+
if not state:
|
| 339 |
+
return None
|
| 340 |
+
try:
|
| 341 |
+
return jwt.decode(state, _auth.AUTH_SECRET,
|
| 342 |
+
algorithms=[SESSION_ALGO], audience=_STATE_AUD)
|
| 343 |
+
except jwt.InvalidTokenError:
|
| 344 |
+
return None
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
# ββ Routes ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 348 |
+
|
| 349 |
+
router = APIRouter()
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def _public_user(user: dict) -> dict:
|
| 353 |
+
return {"id": user["id"], "email": user["email"],
|
| 354 |
+
"org_id": user["org_id"], "role": user["role"]}
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
class LoginBody(BaseModel):
|
| 358 |
+
email: str
|
| 359 |
+
password: str
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
@router.post("/auth/login")
|
| 363 |
+
def local_login(body: LoginBody):
|
| 364 |
+
"""Local-account login β session JWT with {sub, org_id, role, exp}."""
|
| 365 |
+
_ensure_ready()
|
| 366 |
+
user = get_user_by_email(body.email)
|
| 367 |
+
ok = (user is not None and user.get("pw_hash")
|
| 368 |
+
and verify_password(user["pw_hash"], body.password))
|
| 369 |
+
if not ok:
|
| 370 |
+
time.sleep(0.25) # match legacy /login: slow credential stuffing
|
| 371 |
+
raise HTTPException(401, "invalid credentials")
|
| 372 |
+
return {"token": mint_session(user), "user": _public_user(user)}
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
@router.get("/auth/oidc/login")
|
| 376 |
+
def oidc_login():
|
| 377 |
+
"""Redirect to the IdP's authorization endpoint (code flow)."""
|
| 378 |
+
if not oidc_enabled():
|
| 379 |
+
raise HTTPException(404, "Not Found")
|
| 380 |
+
cfg = _discovery()
|
| 381 |
+
nonce = secrets.token_urlsafe(16)
|
| 382 |
+
params = {
|
| 383 |
+
"response_type": "code",
|
| 384 |
+
"client_id": OIDC_CLIENT_ID,
|
| 385 |
+
"redirect_uri": OIDC_REDIRECT_URL,
|
| 386 |
+
"scope": "openid email profile",
|
| 387 |
+
"state": _mint_state(nonce),
|
| 388 |
+
"nonce": nonce,
|
| 389 |
+
}
|
| 390 |
+
return RedirectResponse(
|
| 391 |
+
cfg["authorization_endpoint"] + "?" + urlencode(params),
|
| 392 |
+
status_code=302,
|
| 393 |
+
)
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
@router.get("/auth/oidc/callback")
|
| 397 |
+
def oidc_callback(code: str = "", state: str = "", error: str = "",
|
| 398 |
+
error_description: str = ""):
|
| 399 |
+
"""Exchange the code, verify the id_token, provision/link, mint session."""
|
| 400 |
+
if not oidc_enabled():
|
| 401 |
+
raise HTTPException(404, "Not Found")
|
| 402 |
+
_ensure_ready()
|
| 403 |
+
if error:
|
| 404 |
+
raise HTTPException(400, f"OIDC error: {error} {error_description}".strip())
|
| 405 |
+
st = _verify_state(state)
|
| 406 |
+
if st is None:
|
| 407 |
+
raise HTTPException(400, "invalid or expired state")
|
| 408 |
+
if not code:
|
| 409 |
+
raise HTTPException(400, "missing code")
|
| 410 |
+
|
| 411 |
+
cfg = _discovery()
|
| 412 |
+
try:
|
| 413 |
+
resp = httpx.post(
|
| 414 |
+
cfg["token_endpoint"],
|
| 415 |
+
data={
|
| 416 |
+
"grant_type": "authorization_code",
|
| 417 |
+
"code": code,
|
| 418 |
+
"redirect_uri": OIDC_REDIRECT_URL,
|
| 419 |
+
"client_id": OIDC_CLIENT_ID,
|
| 420 |
+
"client_secret": OIDC_CLIENT_SECRET,
|
| 421 |
+
},
|
| 422 |
+
timeout=10.0,
|
| 423 |
+
)
|
| 424 |
+
resp.raise_for_status()
|
| 425 |
+
id_token = resp.json().get("id_token", "")
|
| 426 |
+
except httpx.HTTPError as e:
|
| 427 |
+
raise HTTPException(502, f"OIDC token exchange failed: {e.__class__.__name__}") from e
|
| 428 |
+
if not id_token:
|
| 429 |
+
raise HTTPException(502, "OIDC token response had no id_token")
|
| 430 |
+
|
| 431 |
+
try:
|
| 432 |
+
signing_key = _jwks_client(cfg["jwks_uri"]).get_signing_key_from_jwt(id_token)
|
| 433 |
+
claims = jwt.decode(
|
| 434 |
+
id_token,
|
| 435 |
+
signing_key.key,
|
| 436 |
+
algorithms=_ID_TOKEN_ALGOS,
|
| 437 |
+
audience=OIDC_CLIENT_ID,
|
| 438 |
+
issuer=cfg["issuer"],
|
| 439 |
+
leeway=30,
|
| 440 |
+
)
|
| 441 |
+
except jwt.PyJWKClientError as e:
|
| 442 |
+
raise HTTPException(502, f"OIDC JWKS fetch failed: {e.__class__.__name__}") from e
|
| 443 |
+
except jwt.InvalidTokenError:
|
| 444 |
+
raise HTTPException(401, "invalid id_token")
|
| 445 |
+
if claims.get("nonce") != st["nonce"]:
|
| 446 |
+
raise HTTPException(401, "nonce mismatch")
|
| 447 |
+
|
| 448 |
+
iss, sub = claims["iss"], claims["sub"]
|
| 449 |
+
user = get_user_by_oidc(iss, sub)
|
| 450 |
+
if user is None:
|
| 451 |
+
email = (claims.get("email") or "").strip().lower()
|
| 452 |
+
if not email:
|
| 453 |
+
raise HTTPException(403, "id_token has no email claim")
|
| 454 |
+
if claims.get("email_verified") is False:
|
| 455 |
+
raise HTTPException(403, "email not verified by identity provider")
|
| 456 |
+
existing = get_user_by_email(email)
|
| 457 |
+
if existing is not None:
|
| 458 |
+
# Same verified email as an existing account β link identities.
|
| 459 |
+
link_user_oidc(existing["id"], iss, sub)
|
| 460 |
+
user = get_user_by_email(email)
|
| 461 |
+
else:
|
| 462 |
+
domain = email.split("@", 1)[1]
|
| 463 |
+
org = get_org_by_domain(domain)
|
| 464 |
+
if org is None:
|
| 465 |
+
raise HTTPException(403, "no org for domain")
|
| 466 |
+
user = create_user(
|
| 467 |
+
email=email,
|
| 468 |
+
org_id=org["id"],
|
| 469 |
+
role="viewer", # OIDC default; org admins promote later
|
| 470 |
+
oidc_iss=iss,
|
| 471 |
+
oidc_sub=sub,
|
| 472 |
+
display_name=claims.get("name") or email,
|
| 473 |
+
)
|
| 474 |
+
return {"token": mint_session(user), "user": _public_user(user)}
|
api/knowledge.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Company-knowledge API routes β Phase 6 (docs/HARDENING.md first-customer UX).
|
| 3 |
+
|
| 4 |
+
Mounted by api/server.py:
|
| 5 |
+
from api.knowledge import router as knowledge_router
|
| 6 |
+
app.include_router(knowledge_router)
|
| 7 |
+
|
| 8 |
+
Endpoints (bearer-gated by the default auth middleware β api/auth.py):
|
| 9 |
+
POST /knowledge/docs β multipart upload (md/txt/pdf,
|
| 10 |
+
<= 5 MB) β doc dict [admin|sme]
|
| 11 |
+
GET /knowledge/docs β org's docs, summaries only [any role]
|
| 12 |
+
GET /knowledge/docs/{id} β full decrypted text [admin|sme]
|
| 13 |
+
POST /knowledge/drafts β {docIds, title} β generated L5
|
| 14 |
+
draft (blueprint + decrypted items
|
| 15 |
+
for review) [admin|sme]
|
| 16 |
+
GET /knowledge/drafts β org's drafts [any role; items (T2)
|
| 17 |
+
included only for admin|sme]
|
| 18 |
+
POST /knowledge/drafts/{id}/approve β {note} β sign-off dict [sme ONLY]
|
| 19 |
+
POST /knowledge/drafts/{id}/reject β {note} β sign-off dict [sme ONLY]
|
| 20 |
+
POST /knowledge/drafts/{id}/exam β {candidateSpec?, agentId?,
|
| 21 |
+
dryRun=true} β {jobId}; job-backed
|
| 22 |
+
like /atp/exams/run [admin|sme]
|
| 23 |
+
|
| 24 |
+
Tenancy (docs/TENANCY.md β this is T2 "crown jewels" data):
|
| 25 |
+
* org_id comes ONLY from verified request state (request.state.org_id, set
|
| 26 |
+
by the auth middleware from the JWT) β never from body/query. All storage
|
| 27 |
+
is org-scoped + encrypted at rest in atp/knowledge.py.
|
| 28 |
+
* Uploads are stored as encrypted DB columns ONLY β no file ever lands on
|
| 29 |
+
disk, and in particular never under the public /videos or /media
|
| 30 |
+
prefixes (leak surface #4).
|
| 31 |
+
* Cross-tenant/unknown ids β the same 404 (no existence oracle, #6).
|
| 32 |
+
|
| 33 |
+
Separation of duties: admins (or SMEs) manage docs and generate drafts, but
|
| 34 |
+
ONLY role 'sme' can approve/reject β an admin must not be able to sign off
|
| 35 |
+
the exam content they themselves assembled. The BU_AUTH_DISABLED=1 dev
|
| 36 |
+
bypass (which runs every request as org-demo with role 'admin') is exempted
|
| 37 |
+
from that one gate so the demo flow keeps working end-to-end; with auth
|
| 38 |
+
enabled there is no exemption.
|
| 39 |
+
"""
|
| 40 |
+
|
| 41 |
+
from __future__ import annotations
|
| 42 |
+
|
| 43 |
+
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
|
| 44 |
+
from pydantic import BaseModel
|
| 45 |
+
|
| 46 |
+
from api.auth import require_role
|
| 47 |
+
from atp import knowledge
|
| 48 |
+
|
| 49 |
+
router = APIRouter(prefix="/knowledge")
|
| 50 |
+
|
| 51 |
+
#: Upload size cap (bytes) β Phase 6 contract: 5 MB.
|
| 52 |
+
MAX_UPLOAD_BYTES = 5 * 1024 * 1024
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# ββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 56 |
+
|
| 57 |
+
def _org(request: Request) -> str:
|
| 58 |
+
"""Org for this request β ONLY from verified auth state (TENANCY.md Β§4
|
| 59 |
+
layer 1). 'org-demo' when auth is disabled / legacy single-admin token."""
|
| 60 |
+
return getattr(request.state, "org_id", None) or "org-demo"
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _user(request: Request) -> str:
|
| 64 |
+
return getattr(request.state, "user", None) or "anon"
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def require_sme(request: Request) -> str:
|
| 68 |
+
"""Sign-off gate: role 'sme' ONLY (separation of duties β admins manage,
|
| 69 |
+
SMEs sign). Dev exception: the BU_AUTH_DISABLED=1 bypass carries role
|
| 70 |
+
'admin' for every request (api/auth.py), so it is allowed through here β
|
| 71 |
+
otherwise the demo could never exercise approval. With auth enabled the
|
| 72 |
+
gate is strict."""
|
| 73 |
+
role = getattr(request.state, "role", None)
|
| 74 |
+
if role is None:
|
| 75 |
+
raise HTTPException(
|
| 76 |
+
status_code=401, detail="authentication required",
|
| 77 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 78 |
+
)
|
| 79 |
+
from api import auth as _auth
|
| 80 |
+
if role != "sme" and not _auth.AUTH_DISABLED:
|
| 81 |
+
raise HTTPException(
|
| 82 |
+
403, "draft sign-off requires role 'sme' (separation of duties: "
|
| 83 |
+
"admins manage documents and drafts; SMEs sign)")
|
| 84 |
+
return role
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _http_errors(fn, *args, **kwargs):
|
| 88 |
+
"""Run a knowledge-module call, translating its typed errors to HTTP.
|
| 89 |
+
|
| 90 |
+
UnsupportedDocumentError β 415, KnowledgeError β 400, DraftStateError β
|
| 91 |
+
409. The fail-loud missing-DATA_ENCRYPTION_KEY RuntimeError from
|
| 92 |
+
atp/crypto.py becomes a 503 with an actionable message (T2 content is
|
| 93 |
+
NEVER written or served as plaintext instead)."""
|
| 94 |
+
try:
|
| 95 |
+
return fn(*args, **kwargs)
|
| 96 |
+
except knowledge.UnsupportedDocumentError as e:
|
| 97 |
+
raise HTTPException(415, str(e)) from None
|
| 98 |
+
except knowledge.KnowledgeError as e:
|
| 99 |
+
raise HTTPException(400, str(e)) from None
|
| 100 |
+
except knowledge.DraftStateError as e:
|
| 101 |
+
raise HTTPException(409, str(e)) from None
|
| 102 |
+
except RuntimeError as e:
|
| 103 |
+
if "DATA_ENCRYPTION_KEY" in str(e):
|
| 104 |
+
raise HTTPException(
|
| 105 |
+
503, "DATA_ENCRYPTION_KEY is not configured on this "
|
| 106 |
+
"deployment β company-knowledge content is T2 and is "
|
| 107 |
+
"only ever stored/served encrypted (docs/TENANCY.md)",
|
| 108 |
+
) from None
|
| 109 |
+
raise
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
# ββ Documents ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 113 |
+
|
| 114 |
+
@router.post("/docs", dependencies=[Depends(require_role("admin", "sme"))])
|
| 115 |
+
async def upload_doc(request: Request, file: UploadFile = File(...)):
|
| 116 |
+
"""Upload one company document (md/txt/pdf, <= 5 MB) into the org vault.
|
| 117 |
+
|
| 118 |
+
Content is encrypted under the org's subkey before it touches the DB; the
|
| 119 |
+
response returns metadata + the heuristic summary, not the full text.
|
| 120 |
+
"""
|
| 121 |
+
if knowledge.doc_kind(file.filename, file.content_type) is None:
|
| 122 |
+
raise HTTPException(
|
| 123 |
+
415, f"unsupported document type (filename={file.filename!r}, "
|
| 124 |
+
f"content-type={file.content_type!r}) β allowed: .md, .txt, "
|
| 125 |
+
f".pdf")
|
| 126 |
+
data = await file.read(MAX_UPLOAD_BYTES + 1)
|
| 127 |
+
if len(data) > MAX_UPLOAD_BYTES:
|
| 128 |
+
raise HTTPException(
|
| 129 |
+
413, f"file exceeds the {MAX_UPLOAD_BYTES // (1024 * 1024)} MB "
|
| 130 |
+
f"upload cap")
|
| 131 |
+
if not data:
|
| 132 |
+
raise HTTPException(400, "empty upload")
|
| 133 |
+
return _http_errors(
|
| 134 |
+
knowledge.ingest_doc, _org(request), file.filename,
|
| 135 |
+
file.content_type, data, uploaded_by=_user(request))
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
@router.get("/docs", dependencies=[Depends(require_role())])
|
| 139 |
+
def get_docs(request: Request):
|
| 140 |
+
"""Org's vault docs, latest first β decrypted SUMMARIES only (any
|
| 141 |
+
authenticated role; full text stays behind the admin|sme route)."""
|
| 142 |
+
return {"docs": _http_errors(knowledge.list_docs, _org(request))}
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
@router.get("/docs/{doc_id}",
|
| 146 |
+
dependencies=[Depends(require_role("admin", "sme"))])
|
| 147 |
+
def get_doc(doc_id: str, request: Request):
|
| 148 |
+
"""One doc with the full decrypted text (admin|sme). Cross-tenant ids
|
| 149 |
+
404 with the SAME message as unknown ids (TENANCY.md #6)."""
|
| 150 |
+
doc = _http_errors(knowledge.get_doc, _org(request), doc_id)
|
| 151 |
+
if doc is None:
|
| 152 |
+
raise HTTPException(404, f"doc {doc_id} not found")
|
| 153 |
+
return doc
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
# ββ L5 drafts ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 157 |
+
|
| 158 |
+
class DraftBody(BaseModel):
|
| 159 |
+
docIds: list[str]
|
| 160 |
+
title: str = ""
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
@router.post("/drafts", dependencies=[Depends(require_role("admin", "sme"))])
|
| 164 |
+
def create_draft(body: DraftBody, request: Request):
|
| 165 |
+
"""Generate a deterministic L5 exam draft from the given vault docs
|
| 166 |
+
(blueprint sections = doc titles; >= 8 recall/apply items). The response
|
| 167 |
+
includes the DECRYPTED items so the reviewer can read them β this route
|
| 168 |
+
is admin|sme for exactly that reason."""
|
| 169 |
+
return _http_errors(
|
| 170 |
+
knowledge.generate_l5_draft, _org(request), body.docIds,
|
| 171 |
+
created_by=_user(request), title=body.title)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
@router.get("/drafts")
|
| 175 |
+
def get_drafts(request: Request, role: str = Depends(require_role())):
|
| 176 |
+
"""Org's drafts, latest first (any authenticated role). The decrypted
|
| 177 |
+
items (T2 β verbatim company passages) are included only for admin|sme;
|
| 178 |
+
viewers get blueprint + status metadata."""
|
| 179 |
+
include_items = role in ("admin", "sme")
|
| 180 |
+
return {"drafts": _http_errors(
|
| 181 |
+
knowledge.list_drafts, _org(request), include_items=include_items)}
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
class ReviewBody(BaseModel):
|
| 185 |
+
note: str = ""
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
@router.post("/drafts/{draft_id}/approve",
|
| 189 |
+
dependencies=[Depends(require_sme)])
|
| 190 |
+
def approve_draft(draft_id: str, body: ReviewBody, request: Request):
|
| 191 |
+
"""SME sign-off (role sme ONLY): flips the draft to 'approved', appends a
|
| 192 |
+
signed 'sme_signoff' evidence row to the org chain, and returns the
|
| 193 |
+
evidence id + examCertRef + examRunnable flag."""
|
| 194 |
+
out = _http_errors(
|
| 195 |
+
knowledge.approve_draft, _org(request), draft_id,
|
| 196 |
+
reviewer=_user(request), note=body.note)
|
| 197 |
+
if out is None:
|
| 198 |
+
raise HTTPException(404, f"draft {draft_id} not found")
|
| 199 |
+
return out
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
@router.post("/drafts/{draft_id}/reject",
|
| 203 |
+
dependencies=[Depends(require_sme)])
|
| 204 |
+
def reject_draft(draft_id: str, body: ReviewBody, request: Request):
|
| 205 |
+
"""SME rejection (role sme ONLY) β same evidence trail as approval,
|
| 206 |
+
payload outcome 'rejected'."""
|
| 207 |
+
out = _http_errors(
|
| 208 |
+
knowledge.reject_draft, _org(request), draft_id,
|
| 209 |
+
reviewer=_user(request), note=body.note)
|
| 210 |
+
if out is None:
|
| 211 |
+
raise HTTPException(404, f"draft {draft_id} not found")
|
| 212 |
+
return out
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
# ββ Exam over the approved draft bank ββββββββββββββββββββββββββββββββββββββββ
|
| 216 |
+
|
| 217 |
+
class DraftExamBody(BaseModel):
|
| 218 |
+
candidateSpec: str | None = None # agents/backend.get_backend spec string
|
| 219 |
+
agentId: str | None = None
|
| 220 |
+
dryRun: bool = True # deterministic stub model (CI-safe)
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
@router.post("/drafts/{draft_id}/exam",
|
| 224 |
+
dependencies=[Depends(require_role("admin", "sme"))])
|
| 225 |
+
def run_draft_exam(draft_id: str, body: DraftExamBody, request: Request):
|
| 226 |
+
"""Run the certification exam against the org's APPROVED draft bank.
|
| 227 |
+
|
| 228 |
+
Job-backed exactly like POST /atp/exams/run: returns {jobId}; poll
|
| 229 |
+
GET /jobs/{jobId} β when status='done' the result is the award dict.
|
| 230 |
+
The bank is resolved through atp/exams.py's Phase 6 loader path
|
| 231 |
+
(ORG_CERT_PREFIX ref β atp/knowledge.py in-memory bank; judge/candidate
|
| 232 |
+
separation and evidence signing apply unchanged).
|
| 233 |
+
"""
|
| 234 |
+
from agents import jobs
|
| 235 |
+
|
| 236 |
+
org = _org(request)
|
| 237 |
+
draft = _http_errors(knowledge.get_draft, org, draft_id)
|
| 238 |
+
if draft is None:
|
| 239 |
+
raise HTTPException(404, f"draft {draft_id} not found")
|
| 240 |
+
if draft["status"] != "approved":
|
| 241 |
+
raise HTTPException(
|
| 242 |
+
409, f"draft {draft_id} is '{draft['status']}' β only an "
|
| 243 |
+
f"SME-approved draft can be examined")
|
| 244 |
+
|
| 245 |
+
cert_ref = knowledge.EXAM_CERT_PREFIX + draft_id
|
| 246 |
+
candidate_spec = body.candidateSpec
|
| 247 |
+
agent_id = body.agentId
|
| 248 |
+
dry_run = body.dryRun
|
| 249 |
+
|
| 250 |
+
def _task():
|
| 251 |
+
from atp import exams
|
| 252 |
+
return exams.run_exam(
|
| 253 |
+
cert_ref,
|
| 254 |
+
candidate_spec,
|
| 255 |
+
org_id=org,
|
| 256 |
+
dry_run=dry_run,
|
| 257 |
+
agent_id=agent_id,
|
| 258 |
+
)
|
| 259 |
+
|
| 260 |
+
job_id = jobs.submit("l5_draft_exam", _task, org_id=org)
|
| 261 |
+
# jobId is the documented key; job_id keeps BU_API.runJob() compatible.
|
| 262 |
+
return {"jobId": job_id, "job_id": job_id, "certRef": cert_ref}
|
api/ops.py
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Runtime ops for the Brain University API β Phase 5 (docs/HARDENING.md).
|
| 3 |
+
|
| 4 |
+
Three concerns, one middleware (`ops_middleware`) plus one renderer
|
| 5 |
+
(`metrics_response` for the /metrics route in api/server.py):
|
| 6 |
+
|
| 7 |
+
1. RATE LIMITING β in-process token bucket, keyed (client IP, token subject).
|
| 8 |
+
* Env `BU_RATE_LIMIT` = "<requests>/<window_seconds>", default "120/60"
|
| 9 |
+
(120 requests per rolling 60s per key). `BU_RATE_LIMIT=0` disables.
|
| 10 |
+
Malformed values warn to stderr and fall back to the default (an ops
|
| 11 |
+
typo must never boot an unlimited OR a bricked API).
|
| 12 |
+
* The middleware is registered so it runs AFTER auth (see api/server.py):
|
| 13 |
+
request.state.user is already populated, so one NATed office IP full of
|
| 14 |
+
distinct authenticated users doesn't share a single bucket β but an
|
| 15 |
+
unauthenticated scanner (no subject) still collapses to its IP.
|
| 16 |
+
* Over-limit β 429 with a `Retry-After: <seconds>` header (seconds until
|
| 17 |
+
the bucket next holds a full token).
|
| 18 |
+
* Exempt: /health (liveness probes must never 429), /metrics (scrapers),
|
| 19 |
+
and OPTIONS (CORS preflight carries no credentials to key on).
|
| 20 |
+
* Buckets live in a bounded LRU (`MAX_BUCKET_KEYS` = 10k keys, least-
|
| 21 |
+
recently-seen evicted) so hostile key churn can't grow memory unbounded.
|
| 22 |
+
* Client IP is request.client.host β behind a reverse proxy (Render,
|
| 23 |
+
Netlify redirects) that is the proxy's address unless uvicorn runs with
|
| 24 |
+
--proxy-headers (or ProxyHeadersMiddleware) so X-Forwarded-For rewrites
|
| 25 |
+
scope['client']. Without it, all remote clients share one IP and the
|
| 26 |
+
bucket key degrades to per-token-subject β still functional, coarser.
|
| 27 |
+
* SCOPE: single-instance only, by design. Buckets are per-process memory β
|
| 28 |
+
N instances behind a load balancer enforce NΓlimit, and a restart resets
|
| 29 |
+
all buckets. Good enough for the current single-box Render deploy; for
|
| 30 |
+
multi-instance, swap `TokenBucketLimiter` for a Redis-backed bucket
|
| 31 |
+
(INCR + EXPIRE, or a Lua token bucket) β the middleware contract
|
| 32 |
+
(`acquire(key) -> (allowed, retry_after_s)`) is the seam.
|
| 33 |
+
* BU_AUTH_DISABLED=1 demo mode is untouched: the limiter still runs (it
|
| 34 |
+
needs no auth), keyed on (ip, dev-admin); set BU_RATE_LIMIT=0 to switch
|
| 35 |
+
it off entirely.
|
| 36 |
+
|
| 37 |
+
2. /metrics β Prometheus text exposition format 0.0.4, HAND-ROLLED on
|
| 38 |
+
purpose: no `prometheus-client` dependency (documented decision β the
|
| 39 |
+
series below are counters/gauges/one histogram with a small fixed label
|
| 40 |
+
set; a registry library buys nothing and requirements.txt is heavy
|
| 41 |
+
enough). Series:
|
| 42 |
+
bu_up 1 β process serving requests
|
| 43 |
+
bu_requests_total{method,path,status} β counter
|
| 44 |
+
bu_request_duration_seconds*{path,status} β histogram (+_sum/_count)
|
| 45 |
+
bu_rate_limited_total β counter (429s issued here)
|
| 46 |
+
bu_jobs{status} β gauge, DB, cached 5s
|
| 47 |
+
bu_evidence_chain_length β gauge, DB, cached 30s
|
| 48 |
+
bu_licenses{status} β gauge, DB, cached 30s
|
| 49 |
+
Label cardinality is BOUNDED: `path` is always the ROUTE TEMPLATE
|
| 50 |
+
(`/atp/evidence/{evidence_id}`, never the raw id), and any path that
|
| 51 |
+
matches no registered route collapses to the single sentinel
|
| 52 |
+
`/_unmatched` β a scanner spraying random URLs cannot mint new series.
|
| 53 |
+
DB-backed gauges query through atp/tenant_db (org-demo scope β cheap
|
| 54 |
+
bootstrap-org aggregates, NOT cross-tenant sums) and every failure is
|
| 55 |
+
swallowed: a broken DB drops those series from the scrape, it never 500s
|
| 56 |
+
/metrics.
|
| 57 |
+
VISIBILITY TRADEOFF: /metrics is added to the auth allowlist by default
|
| 58 |
+
because Prometheus scrapers don't hold bearers and the payload carries no
|
| 59 |
+
tenant data (aggregate counts + route templates only). It DOES leak
|
| 60 |
+
coarse operational shape (traffic volume, error rates, license counts) to
|
| 61 |
+
anyone who can reach the port β set `BU_METRICS_PUBLIC=0` to keep it
|
| 62 |
+
behind the bearer gate and give the scraper a token instead.
|
| 63 |
+
|
| 64 |
+
3. STRUCTURED REQUEST LOGS β one JSON object per line to stdout:
|
| 65 |
+
{"ts", "method", "path", "status", "ms", "org", "user", "ip"}
|
| 66 |
+
* `path` is the route template (same bounded collapse as metrics).
|
| 67 |
+
* Opt-in via `BU_LOG_JSON=1` (default OFF so `uvicorn --reload` dev logs
|
| 68 |
+
stay human-readable). Read per-request, so tests can flip it.
|
| 69 |
+
* REDACTION BY CONSTRUCTION: the log record is built from the fixed field
|
| 70 |
+
list above β request bodies, query strings, and headers (Authorization,
|
| 71 |
+
X-ATP-License-Key, cookies, ...) are never read by the logger at all.
|
| 72 |
+
Keep it that way: add fields to `_JSON_LOG_FIELDS`, never dump
|
| 73 |
+
request.headers / body. Covered by a redaction test.
|
| 74 |
+
|
| 75 |
+
Wiring (api/server.py): `app.middleware("http")(ops_middleware)` registered
|
| 76 |
+
BETWEEN the access-log middleware and auth_middleware. Starlette runs the
|
| 77 |
+
last-registered http middleware first, so the runtime order is
|
| 78 |
+
auth β ops β tenant-access-log οΏ½οΏ½ CORS β routes: identity is available for
|
| 79 |
+
the bucket key, and a 429 short-circuits BEFORE the tenant access log, so a
|
| 80 |
+
flood can't amplify into per-request DB writes.
|
| 81 |
+
"""
|
| 82 |
+
|
| 83 |
+
from __future__ import annotations
|
| 84 |
+
|
| 85 |
+
import json
|
| 86 |
+
import math
|
| 87 |
+
import os
|
| 88 |
+
import sys
|
| 89 |
+
import threading
|
| 90 |
+
import time
|
| 91 |
+
from collections import OrderedDict
|
| 92 |
+
from datetime import datetime, timezone
|
| 93 |
+
|
| 94 |
+
from fastapi import Request
|
| 95 |
+
from fastapi.responses import JSONResponse, PlainTextResponse
|
| 96 |
+
|
| 97 |
+
# ββ Config βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 98 |
+
|
| 99 |
+
DEFAULT_RATE_LIMIT = "120/60"
|
| 100 |
+
MAX_BUCKET_KEYS = 10_000
|
| 101 |
+
|
| 102 |
+
#: Paths the rate limiter never touches (liveness probes + scrapers).
|
| 103 |
+
RATE_LIMIT_EXEMPT_PATHS = frozenset({"/health", "/metrics"})
|
| 104 |
+
|
| 105 |
+
#: /metrics on the auth allowlist? Default yes β see module docstring
|
| 106 |
+
#: tradeoff. BU_METRICS_PUBLIC=0 keeps it behind the bearer gate.
|
| 107 |
+
METRICS_PUBLIC = os.environ.get("BU_METRICS_PUBLIC", "1") != "0"
|
| 108 |
+
|
| 109 |
+
METRICS_CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8"
|
| 110 |
+
|
| 111 |
+
#: The ONLY fields a JSON request-log line may carry (redaction by
|
| 112 |
+
#: construction β see module docstring Β§3).
|
| 113 |
+
_JSON_LOG_FIELDS = ("ts", "method", "path", "status", "ms", "org", "user", "ip")
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _log_json_enabled() -> bool:
|
| 117 |
+
"""BU_LOG_JSON read per-request: costs ~Β΅s, keeps tests/env flips live."""
|
| 118 |
+
return os.environ.get("BU_LOG_JSON", "") == "1"
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
# ββ Token-bucket rate limiter (bounded LRU of buckets) βββββββββββββββββββββ
|
| 122 |
+
|
| 123 |
+
class TokenBucketLimiter:
|
| 124 |
+
"""Classic token bucket per key, stored in a size-bounded LRU.
|
| 125 |
+
|
| 126 |
+
capacity = `limit` tokens, refilled continuously at limit/window per
|
| 127 |
+
second β a full burst of `limit` is allowed instantly, sustained rate
|
| 128 |
+
converges to limit/window. Single-process scope (module docstring Β§1).
|
| 129 |
+
"""
|
| 130 |
+
|
| 131 |
+
def __init__(self, limit: int, window_s: int,
|
| 132 |
+
max_keys: int = MAX_BUCKET_KEYS):
|
| 133 |
+
if limit <= 0 or window_s <= 0:
|
| 134 |
+
raise ValueError("limit and window must be positive")
|
| 135 |
+
self.limit = limit
|
| 136 |
+
self.window_s = window_s
|
| 137 |
+
self.capacity = float(limit)
|
| 138 |
+
self.rate = limit / window_s # tokens per second
|
| 139 |
+
self.max_keys = max_keys
|
| 140 |
+
self._buckets: OrderedDict[str, tuple[float, float]] = OrderedDict()
|
| 141 |
+
self._lock = threading.Lock() # sync routes run off-loop; be safe
|
| 142 |
+
|
| 143 |
+
def acquire(self, key: str, now: float | None = None) -> tuple[bool, int]:
|
| 144 |
+
"""Try to take one token for `key` β (allowed, retry_after_seconds).
|
| 145 |
+
|
| 146 |
+
retry_after is 0 when allowed, else the whole seconds until the
|
| 147 |
+
bucket next holds >= 1 token (what we put in Retry-After).
|
| 148 |
+
"""
|
| 149 |
+
if now is None:
|
| 150 |
+
now = time.monotonic()
|
| 151 |
+
with self._lock:
|
| 152 |
+
tokens, last = self._buckets.get(key, (self.capacity, now))
|
| 153 |
+
tokens = min(self.capacity, tokens + (now - last) * self.rate)
|
| 154 |
+
if tokens >= 1.0:
|
| 155 |
+
tokens -= 1.0
|
| 156 |
+
allowed, retry = True, 0
|
| 157 |
+
else:
|
| 158 |
+
allowed = False
|
| 159 |
+
retry = max(1, math.ceil((1.0 - tokens) / self.rate))
|
| 160 |
+
self._buckets[key] = (tokens, now)
|
| 161 |
+
self._buckets.move_to_end(key)
|
| 162 |
+
while len(self._buckets) > self.max_keys: # bounded memory
|
| 163 |
+
self._buckets.popitem(last=False) # evict least-recent
|
| 164 |
+
return allowed, retry
|
| 165 |
+
|
| 166 |
+
def __len__(self) -> int:
|
| 167 |
+
return len(self._buckets)
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def _parse_rate_limit(spec: str) -> tuple[int, int] | None:
|
| 171 |
+
"""'120/60' β (120, 60); '0' β None (disabled). Raises on garbage."""
|
| 172 |
+
spec = (spec or "").strip()
|
| 173 |
+
if spec == "0":
|
| 174 |
+
return None
|
| 175 |
+
n, _, w = spec.partition("/")
|
| 176 |
+
limit, window = int(n), int(w)
|
| 177 |
+
if limit <= 0 or window <= 0:
|
| 178 |
+
raise ValueError(spec)
|
| 179 |
+
return limit, window
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
_LIMITER: TokenBucketLimiter | None = None
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def set_rate_limit(spec: str | None = None) -> None:
|
| 186 |
+
"""(Re)configure the limiter from `spec`, or from env when None.
|
| 187 |
+
|
| 188 |
+
Called once at import; exported so tests (and an ops REPL) can retune
|
| 189 |
+
without reloading the module. Malformed spec β warn + default, never
|
| 190 |
+
crash or silently disable.
|
| 191 |
+
"""
|
| 192 |
+
global _LIMITER
|
| 193 |
+
if spec is None:
|
| 194 |
+
spec = os.environ.get("BU_RATE_LIMIT", DEFAULT_RATE_LIMIT)
|
| 195 |
+
try:
|
| 196 |
+
parsed = _parse_rate_limit(spec)
|
| 197 |
+
except (ValueError, TypeError):
|
| 198 |
+
print(f"WARNING: BU_RATE_LIMIT={spec!r} is malformed β expected "
|
| 199 |
+
f"'<requests>/<seconds>' or '0'; using default "
|
| 200 |
+
f"{DEFAULT_RATE_LIMIT!r}.", file=sys.stderr)
|
| 201 |
+
parsed = _parse_rate_limit(DEFAULT_RATE_LIMIT)
|
| 202 |
+
_LIMITER = None if parsed is None else TokenBucketLimiter(*parsed)
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
set_rate_limit()
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
# ββ Metrics registry (stdlib, one lock, bounded labels) ββββββββββββββββββοΏ½οΏ½β
|
| 209 |
+
|
| 210 |
+
#: Histogram bucket upper bounds (seconds). +Inf is implicit.
|
| 211 |
+
HIST_BUCKETS = (0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0)
|
| 212 |
+
|
| 213 |
+
_KNOWN_METHODS = frozenset(
|
| 214 |
+
{"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"})
|
| 215 |
+
|
| 216 |
+
_METRICS_LOCK = threading.Lock()
|
| 217 |
+
_STARTED_AT = time.time()
|
| 218 |
+
_REQUESTS: dict[tuple[str, str, int], int] = {} # (method,path,status)
|
| 219 |
+
_HIST: dict[tuple[str, int], list] = {} # (path,status) β
|
| 220 |
+
# [bucket_counts, sum, count]
|
| 221 |
+
_RATE_LIMITED = 0
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def _observe(method: str, path_tmpl: str, status: int, elapsed_s: float) -> None:
|
| 225 |
+
method = method if method in _KNOWN_METHODS else "OTHER"
|
| 226 |
+
rkey = (method, path_tmpl, status)
|
| 227 |
+
hkey = (path_tmpl, status)
|
| 228 |
+
with _METRICS_LOCK:
|
| 229 |
+
_REQUESTS[rkey] = _REQUESTS.get(rkey, 0) + 1
|
| 230 |
+
hist = _HIST.get(hkey)
|
| 231 |
+
if hist is None:
|
| 232 |
+
hist = _HIST[hkey] = [[0] * (len(HIST_BUCKETS) + 1), 0.0, 0]
|
| 233 |
+
counts, _, _ = hist
|
| 234 |
+
for i, le in enumerate(HIST_BUCKETS):
|
| 235 |
+
if elapsed_s <= le:
|
| 236 |
+
counts[i] += 1
|
| 237 |
+
counts[-1] += 1 # +Inf
|
| 238 |
+
hist[1] += elapsed_s
|
| 239 |
+
hist[2] += 1
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def _count_rate_limited() -> None:
|
| 243 |
+
global _RATE_LIMITED
|
| 244 |
+
with _METRICS_LOCK:
|
| 245 |
+
_RATE_LIMITED += 1
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
# ββ Path templating (bounded label set) ββββββββββββββββββββββββββββββββββββ
|
| 249 |
+
|
| 250 |
+
_ROUTE_TABLE: list | None = None
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def _path_template(request: Request) -> str:
|
| 254 |
+
"""Collapse the raw path to its route template, else '/_unmatched'.
|
| 255 |
+
|
| 256 |
+
Fast path: FastAPI puts the matched route in request.scope['route'].
|
| 257 |
+
Fallback (unrouted: 429 short-circuits, 404s, OPTIONS): match against
|
| 258 |
+
every registered route's compiled path_regex β one-time table build.
|
| 259 |
+
Everything that matches nothing shares ONE sentinel label so arbitrary
|
| 260 |
+
client paths can never mint new series.
|
| 261 |
+
"""
|
| 262 |
+
route = request.scope.get("route")
|
| 263 |
+
fmt = getattr(route, "path_format", None)
|
| 264 |
+
if fmt:
|
| 265 |
+
return fmt
|
| 266 |
+
global _ROUTE_TABLE
|
| 267 |
+
if _ROUTE_TABLE is None:
|
| 268 |
+
table = []
|
| 269 |
+
for r in request.app.routes: # fully registered before 1st request
|
| 270 |
+
rx = getattr(r, "path_regex", None)
|
| 271 |
+
f = getattr(r, "path_format", None)
|
| 272 |
+
if rx is not None and f:
|
| 273 |
+
table.append((rx, f))
|
| 274 |
+
_ROUTE_TABLE = table
|
| 275 |
+
path = request.url.path
|
| 276 |
+
for rx, f in _ROUTE_TABLE:
|
| 277 |
+
if rx.match(path):
|
| 278 |
+
return f
|
| 279 |
+
return "/_unmatched"
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
# ββ DB-backed gauges (cached; failures drop the series, never 500) βββββββββ
|
| 283 |
+
|
| 284 |
+
_DB_CACHE: dict[str, tuple[float, object]] = {}
|
| 285 |
+
_DB_CACHE_LOCK = threading.Lock()
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
def _cached(name: str, ttl_s: float, fn):
|
| 289 |
+
"""Value of fn(), refreshed at most every ttl_s. On error: serve the
|
| 290 |
+
last good value if any (marked fresh again, so a broken DB is re-probed
|
| 291 |
+
only once per ttl β /metrics never hammers or propagates)."""
|
| 292 |
+
now = time.time()
|
| 293 |
+
with _DB_CACHE_LOCK:
|
| 294 |
+
hit = _DB_CACHE.get(name)
|
| 295 |
+
if hit is not None and hit[0] > now:
|
| 296 |
+
return hit[1]
|
| 297 |
+
try:
|
| 298 |
+
value = fn()
|
| 299 |
+
except Exception: # noqa: BLE001 β metrics must never take the API down
|
| 300 |
+
value = hit[1] if hit is not None else None
|
| 301 |
+
with _DB_CACHE_LOCK:
|
| 302 |
+
_DB_CACHE[name] = (now + ttl_s, value)
|
| 303 |
+
return value
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
def _jobs_by_status() -> dict[str, int]:
|
| 307 |
+
from atp import tenant_db
|
| 308 |
+
org = tenant_db.DEFAULT_ORG
|
| 309 |
+
rows = tenant_db.scoped_query(
|
| 310 |
+
org,
|
| 311 |
+
"SELECT status, COUNT(*) AS n FROM jobs "
|
| 312 |
+
"WHERE org_id = :org GROUP BY status",
|
| 313 |
+
{"org": org},
|
| 314 |
+
)
|
| 315 |
+
out = {"queued": 0, "running": 0, "done": 0, "error": 0}
|
| 316 |
+
for r in rows:
|
| 317 |
+
out[str(r["status"])] = int(r["n"])
|
| 318 |
+
return out
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
def _evidence_chain_length() -> int:
|
| 322 |
+
from atp import tenant_db
|
| 323 |
+
org = tenant_db.DEFAULT_ORG
|
| 324 |
+
rows = tenant_db.scoped_query(
|
| 325 |
+
org,
|
| 326 |
+
"SELECT COUNT(*) AS n FROM atp_evidence WHERE org_id = :org",
|
| 327 |
+
{"org": org},
|
| 328 |
+
)
|
| 329 |
+
return int(rows[0]["n"])
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
def _licenses_by_status() -> dict[str, int]:
|
| 333 |
+
from atp import tenant_db
|
| 334 |
+
org = tenant_db.DEFAULT_ORG
|
| 335 |
+
rows = tenant_db.scoped_query(
|
| 336 |
+
org,
|
| 337 |
+
"SELECT status, COUNT(*) AS n FROM licenses "
|
| 338 |
+
"WHERE org_id = :org GROUP BY status",
|
| 339 |
+
{"org": org},
|
| 340 |
+
)
|
| 341 |
+
out = {"active": 0, "revoked": 0} # always emit the two headline series
|
| 342 |
+
for r in rows:
|
| 343 |
+
out[str(r["status"])] = int(r["n"])
|
| 344 |
+
return out
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
# ββ Exposition (Prometheus text format 0.0.4) ββββββββββββββββββββββββββββββ
|
| 348 |
+
|
| 349 |
+
def _esc(v: str) -> str:
|
| 350 |
+
"""Escape a label value per the exposition format."""
|
| 351 |
+
return v.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
def render_metrics() -> str:
|
| 355 |
+
lines: list[str] = []
|
| 356 |
+
add = lines.append
|
| 357 |
+
|
| 358 |
+
add("# HELP bu_up 1 while the API process is up and serving.")
|
| 359 |
+
add("# TYPE bu_up gauge")
|
| 360 |
+
add("bu_up 1")
|
| 361 |
+
add("# HELP bu_process_start_time_seconds Unix time the process started.")
|
| 362 |
+
add("# TYPE bu_process_start_time_seconds gauge")
|
| 363 |
+
add(f"bu_process_start_time_seconds {_STARTED_AT:.3f}")
|
| 364 |
+
|
| 365 |
+
with _METRICS_LOCK:
|
| 366 |
+
requests = dict(_REQUESTS)
|
| 367 |
+
hist = {k: [list(v[0]), v[1], v[2]] for k, v in _HIST.items()}
|
| 368 |
+
rate_limited = _RATE_LIMITED
|
| 369 |
+
|
| 370 |
+
add("# HELP bu_requests_total HTTP requests by method, route template, "
|
| 371 |
+
"and status.")
|
| 372 |
+
add("# TYPE bu_requests_total counter")
|
| 373 |
+
for (method, path, status), n in sorted(requests.items(),
|
| 374 |
+
key=lambda kv: kv[0]):
|
| 375 |
+
add(f'bu_requests_total{{method="{_esc(method)}",path="{_esc(path)}",'
|
| 376 |
+
f'status="{status}"}} {n}')
|
| 377 |
+
|
| 378 |
+
add("# HELP bu_request_duration_seconds HTTP request latency by route "
|
| 379 |
+
"template and status.")
|
| 380 |
+
add("# TYPE bu_request_duration_seconds histogram")
|
| 381 |
+
for (path, status), (counts, total, count) in sorted(
|
| 382 |
+
hist.items(), key=lambda kv: kv[0]):
|
| 383 |
+
labels = f'path="{_esc(path)}",status="{status}"'
|
| 384 |
+
for i, le in enumerate(HIST_BUCKETS):
|
| 385 |
+
add(f'bu_request_duration_seconds_bucket{{{labels},le="{le}"}} '
|
| 386 |
+
f"{counts[i]}")
|
| 387 |
+
add(f'bu_request_duration_seconds_bucket{{{labels},le="+Inf"}} '
|
| 388 |
+
f"{counts[-1]}")
|
| 389 |
+
add(f"bu_request_duration_seconds_sum{{{labels}}} {total:.6f}")
|
| 390 |
+
add(f"bu_request_duration_seconds_count{{{labels}}} {count}")
|
| 391 |
+
|
| 392 |
+
add("# HELP bu_rate_limited_total Requests rejected with 429 by the "
|
| 393 |
+
"token-bucket limiter.")
|
| 394 |
+
add("# TYPE bu_rate_limited_total counter")
|
| 395 |
+
add(f"bu_rate_limited_total {rate_limited}")
|
| 396 |
+
|
| 397 |
+
jobs = _cached("jobs", 5.0, _jobs_by_status)
|
| 398 |
+
if jobs is not None:
|
| 399 |
+
add("# HELP bu_jobs Background jobs by status (org-demo scope, "
|
| 400 |
+
"cached 5s).")
|
| 401 |
+
add("# TYPE bu_jobs gauge")
|
| 402 |
+
for status, n in sorted(jobs.items()):
|
| 403 |
+
add(f'bu_jobs{{status="{_esc(status)}"}} {n}')
|
| 404 |
+
|
| 405 |
+
chain = _cached("evidence_chain", 30.0, _evidence_chain_length)
|
| 406 |
+
if chain is not None:
|
| 407 |
+
add("# HELP bu_evidence_chain_length Rows in the signed evidence "
|
| 408 |
+
"chain (org-demo scope, cached 30s).")
|
| 409 |
+
add("# TYPE bu_evidence_chain_length gauge")
|
| 410 |
+
add(f"bu_evidence_chain_length {chain}")
|
| 411 |
+
|
| 412 |
+
licenses = _cached("licenses", 30.0, _licenses_by_status)
|
| 413 |
+
if licenses is not None:
|
| 414 |
+
add("# HELP bu_licenses Marketplace licenses by status (org-demo "
|
| 415 |
+
"scope, cached 30s).")
|
| 416 |
+
add("# TYPE bu_licenses gauge")
|
| 417 |
+
for status, n in sorted(licenses.items()):
|
| 418 |
+
add(f'bu_licenses{{status="{_esc(status)}"}} {n}')
|
| 419 |
+
|
| 420 |
+
return "\n".join(lines) + "\n"
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
def metrics_response() -> PlainTextResponse:
|
| 424 |
+
"""Response for the /metrics route (api/server.py owns the route def)."""
|
| 425 |
+
return PlainTextResponse(render_metrics(), media_type=METRICS_CONTENT_TYPE)
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
# ββ JSON request log βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 429 |
+
|
| 430 |
+
def _emit_json_log(request: Request, path_tmpl: str, status: int,
|
| 431 |
+
elapsed_s: float) -> None:
|
| 432 |
+
"""One JSON line to stdout. Fields fixed by _JSON_LOG_FIELDS β never add
|
| 433 |
+
headers or bodies here (redaction by construction; see docstring Β§3)."""
|
| 434 |
+
record = {
|
| 435 |
+
"ts": datetime.now(timezone.utc).isoformat(timespec="milliseconds"),
|
| 436 |
+
"method": request.method,
|
| 437 |
+
"path": path_tmpl,
|
| 438 |
+
"status": status,
|
| 439 |
+
"ms": round(elapsed_s * 1000.0, 2),
|
| 440 |
+
"org": getattr(request.state, "org_id", None),
|
| 441 |
+
"user": getattr(request.state, "user", None),
|
| 442 |
+
"ip": request.client.host if request.client else None,
|
| 443 |
+
}
|
| 444 |
+
assert set(record) == set(_JSON_LOG_FIELDS)
|
| 445 |
+
print(json.dumps(record, separators=(",", ":")), file=sys.stdout,
|
| 446 |
+
flush=True)
|
| 447 |
+
|
| 448 |
+
|
| 449 |
+
# ββ The middleware βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 450 |
+
|
| 451 |
+
async def ops_middleware(request: Request, call_next):
|
| 452 |
+
"""Rate limit (post-auth identity) + metrics + optional JSON log.
|
| 453 |
+
|
| 454 |
+
Runs INSIDE auth (api/server.py registration order), so
|
| 455 |
+
request.state.user/org_id are set for authenticated requests and 401s
|
| 456 |
+
never reach us. A 429 returns straight from here β inner layers (tenant
|
| 457 |
+
access log, CORS, routes) never run for rate-limited requests.
|
| 458 |
+
"""
|
| 459 |
+
start = time.perf_counter()
|
| 460 |
+
method = request.method
|
| 461 |
+
raw_path = request.url.path
|
| 462 |
+
|
| 463 |
+
limiter = _LIMITER
|
| 464 |
+
if (limiter is not None and method != "OPTIONS"
|
| 465 |
+
and raw_path not in RATE_LIMIT_EXEMPT_PATHS):
|
| 466 |
+
ip = request.client.host if request.client else "unknown"
|
| 467 |
+
subject = getattr(request.state, "user", None) or "-"
|
| 468 |
+
allowed, retry_after = limiter.acquire(f"{ip}|{subject}")
|
| 469 |
+
if not allowed:
|
| 470 |
+
_count_rate_limited()
|
| 471 |
+
elapsed = time.perf_counter() - start
|
| 472 |
+
tmpl = _path_template(request)
|
| 473 |
+
_observe(method, tmpl, 429, elapsed)
|
| 474 |
+
if _log_json_enabled():
|
| 475 |
+
_emit_json_log(request, tmpl, 429, elapsed)
|
| 476 |
+
return JSONResponse(
|
| 477 |
+
{"detail": "rate limit exceeded"},
|
| 478 |
+
status_code=429,
|
| 479 |
+
headers={"Retry-After": str(retry_after)},
|
| 480 |
+
)
|
| 481 |
+
|
| 482 |
+
try:
|
| 483 |
+
response = await call_next(request)
|
| 484 |
+
except Exception:
|
| 485 |
+
elapsed = time.perf_counter() - start
|
| 486 |
+
tmpl = _path_template(request)
|
| 487 |
+
_observe(method, tmpl, 500, elapsed)
|
| 488 |
+
if _log_json_enabled():
|
| 489 |
+
_emit_json_log(request, tmpl, 500, elapsed)
|
| 490 |
+
raise
|
| 491 |
+
elapsed = time.perf_counter() - start
|
| 492 |
+
tmpl = _path_template(request)
|
| 493 |
+
_observe(method, tmpl, response.status_code, elapsed)
|
| 494 |
+
if _log_json_enabled():
|
| 495 |
+
_emit_json_log(request, tmpl, response.status_code, elapsed)
|
| 496 |
+
return response
|
api/server.py
CHANGED
|
@@ -35,7 +35,7 @@ from pathlib import Path
|
|
| 35 |
from typing import Any
|
| 36 |
|
| 37 |
try:
|
| 38 |
-
from fastapi import FastAPI, HTTPException, UploadFile, File, Form
|
| 39 |
from fastapi.middleware.cors import CORSMiddleware
|
| 40 |
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
| 41 |
from pydantic import BaseModel
|
|
@@ -64,12 +64,139 @@ app.add_middleware(
|
|
| 64 |
allow_headers=["*"],
|
| 65 |
)
|
| 66 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
# Bearer-token auth on every route except an allowlist (/health, /login,
|
| 68 |
# /docs, /videos, /media, OPTIONS preflight). Configure via env vars
|
| 69 |
# BU_AUTH_USER / BU_AUTH_PASSWORD / BU_AUTH_SECRET. See api/auth.py.
|
| 70 |
from api.auth import auth_middleware, mint_token, check_password
|
| 71 |
app.middleware("http")(auth_middleware)
|
| 72 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
class LoginBody(BaseModel):
|
| 75 |
username: str
|
|
@@ -95,12 +222,11 @@ def _graph():
|
|
| 95 |
"""Build the merged corpus graph + cluster list once and cache."""
|
| 96 |
if "G" in _GRAPH_CACHE:
|
| 97 |
return _GRAPH_CACHE
|
| 98 |
-
from graph.
|
| 99 |
-
from graph.
|
| 100 |
-
|
| 101 |
-
G =
|
| 102 |
-
|
| 103 |
-
foundations = rank_foundations(G, clusters)
|
| 104 |
_GRAPH_CACHE.update(G=G, clusters=clusters, foundations=foundations)
|
| 105 |
return _GRAPH_CACHE
|
| 106 |
|
|
@@ -268,7 +394,7 @@ def podcast_local_interject(body: LocalInterjectBody):
|
|
| 268 |
# ββ Background jobs (async podcast render) ββββββββββββββββββββββββββββββββ
|
| 269 |
|
| 270 |
@app.post("/podcast/render/async")
|
| 271 |
-
def podcast_render_async(body: PodcastRenderBody):
|
| 272 |
"""Enqueue a cloud podcast render; returns a job_id to poll at /jobs/{id}."""
|
| 273 |
from agents import jobs, podcast_video
|
| 274 |
|
|
@@ -278,11 +404,12 @@ def podcast_render_async(body: PodcastRenderBody):
|
|
| 278 |
raise RuntimeError("render failed (check moviepy/gTTS install)")
|
| 279 |
return {k: str(v.relative_to(PROJECT_ROOT)) for k, v in r.items()}
|
| 280 |
|
| 281 |
-
return {"job_id": jobs.submit("podcast_render", task
|
|
|
|
| 282 |
|
| 283 |
|
| 284 |
@app.post("/podcast/local/async")
|
| 285 |
-
def podcast_local_async(body: LocalPodcastBody):
|
| 286 |
"""Enqueue a fully-local podcast render; poll at /jobs/{id}."""
|
| 287 |
from agents import jobs
|
| 288 |
from agents.local_podcast import generate_local
|
|
@@ -294,13 +421,17 @@ def podcast_local_async(body: LocalPodcastBody):
|
|
| 294 |
raise RuntimeError("local podcast generation failed")
|
| 295 |
return {"mp3": str(p.relative_to(PROJECT_ROOT))}
|
| 296 |
|
| 297 |
-
return {"job_id": jobs.submit("podcast_local", task
|
|
|
|
| 298 |
|
| 299 |
|
| 300 |
@app.get("/jobs/{job_id}")
|
| 301 |
-
def job_status(job_id: str):
|
|
|
|
|
|
|
|
|
|
| 302 |
from agents import jobs
|
| 303 |
-
j = jobs.get(job_id)
|
| 304 |
if not j:
|
| 305 |
raise HTTPException(404, f"no such job {job_id}")
|
| 306 |
return j
|
|
@@ -311,6 +442,14 @@ def health():
|
|
| 311 |
return {"ok": True}
|
| 312 |
|
| 313 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
@app.get("/graph")
|
| 315 |
def graph_endpoint():
|
| 316 |
g = _graph()["G"]
|
|
@@ -330,7 +469,7 @@ def trailhead(user_id: str = "anon", interest: str = "", n: int = 5):
|
|
| 330 |
|
| 331 |
def _foundation_card(f) -> dict:
|
| 332 |
return {
|
| 333 |
-
"node": getattr(f, "
|
| 334 |
"score": getattr(f, "score", None),
|
| 335 |
"rationale": getattr(f, "rationale", []),
|
| 336 |
"cluster_id": getattr(f, "cluster_id", None),
|
|
@@ -465,40 +604,24 @@ def briefing_endpoint(node: str, interest: str = ""):
|
|
| 465 |
@app.post("/socratic/start")
|
| 466 |
def socratic_start(body: SocraticStartBody):
|
| 467 |
from agents.socratic import start_session
|
| 468 |
-
from agents.persistence import
|
| 469 |
import uuid
|
| 470 |
state = start_session(body.node)
|
| 471 |
sid = f"soc_{uuid.uuid4().hex[:12]}"
|
| 472 |
-
|
| 473 |
-
db.execute(
|
| 474 |
-
"""INSERT INTO socratic_sessions
|
| 475 |
-
(session_id, user_id, node, state_json, updated_at)
|
| 476 |
-
VALUES (?, ?, ?, ?, ?)""",
|
| 477 |
-
(sid, body.user_id, body.node, json.dumps(state),
|
| 478 |
-
_now()),
|
| 479 |
-
)
|
| 480 |
-
db.commit()
|
| 481 |
return {"session_id": sid, "state": state}
|
| 482 |
|
| 483 |
|
| 484 |
@app.post("/socratic/step")
|
| 485 |
def socratic_step(body: SocraticStepBody):
|
| 486 |
from agents.socratic import step
|
| 487 |
-
from agents.persistence import
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
(body.session_id,),
|
| 492 |
-
).fetchone()
|
| 493 |
-
if not row:
|
| 494 |
raise HTTPException(404, "session not found")
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
db.execute(
|
| 498 |
-
"UPDATE socratic_sessions SET state_json = ?, updated_at = ? WHERE session_id = ?",
|
| 499 |
-
(json.dumps(new_state), _now(), body.session_id),
|
| 500 |
-
)
|
| 501 |
-
db.commit()
|
| 502 |
return new_state
|
| 503 |
|
| 504 |
|
|
|
|
| 35 |
from typing import Any
|
| 36 |
|
| 37 |
try:
|
| 38 |
+
from fastapi import FastAPI, HTTPException, Request, UploadFile, File, Form
|
| 39 |
from fastapi.middleware.cors import CORSMiddleware
|
| 40 |
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
| 41 |
from pydantic import BaseModel
|
|
|
|
| 64 |
allow_headers=["*"],
|
| 65 |
)
|
| 66 |
|
| 67 |
+
# ββ Tenant access audit (Phase 2 β docs/TENANCY.md Β§4 layer 4) ββββββββββββ
|
| 68 |
+
# Starlette runs the LAST-registered http middleware FIRST, so registering
|
| 69 |
+
# this BEFORE auth_middleware (below) makes it the inner layer: on the
|
| 70 |
+
# request path it runs AFTER auth β request.state.{user,org_id,role} are
|
| 71 |
+
# already populated and 401-rejected requests never reach it. Rows are
|
| 72 |
+
# written fire-and-forget on a single-worker pool so the response is never
|
| 73 |
+
# blocked, and every failure (including atp/tenant_db.py not being deployed
|
| 74 |
+
# yet) is swallowed β the audit trail must never take the API down.
|
| 75 |
+
from concurrent.futures import ThreadPoolExecutor as _AccessLogPool
|
| 76 |
+
|
| 77 |
+
_ACCESS_LOG_POOL = _AccessLogPool(max_workers=1, thread_name_prefix="bu-accesslog")
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@app.middleware("http")
|
| 81 |
+
async def access_log_middleware(request: Request, call_next):
|
| 82 |
+
response = await call_next(request)
|
| 83 |
+
try:
|
| 84 |
+
import api.auth as _auth
|
| 85 |
+
if _auth.AUTH_DISABLED: # dev mode: no audit rows
|
| 86 |
+
return response
|
| 87 |
+
if _auth.is_public(request.url.path, request.method):
|
| 88 |
+
return response # public surface: not tenant data
|
| 89 |
+
user = getattr(request.state, "user", None)
|
| 90 |
+
if user is None: # unauthenticated (shouldn't happen
|
| 91 |
+
return response # behind the auth middleware)
|
| 92 |
+
org_id = getattr(request.state, "org_id", None) or "org-demo"
|
| 93 |
+
role = getattr(request.state, "role", None) or "admin"
|
| 94 |
+
method, path = request.method, request.url.path
|
| 95 |
+
status = response.status_code
|
| 96 |
+
|
| 97 |
+
def _write() -> None:
|
| 98 |
+
try:
|
| 99 |
+
from atp import tenant_db
|
| 100 |
+
tenant_db.log_access(
|
| 101 |
+
org_id=org_id, user=user, role=role,
|
| 102 |
+
method=method, path=path, status=status,
|
| 103 |
+
)
|
| 104 |
+
except Exception: # noqa: BLE001 β fire-and-forget by contract
|
| 105 |
+
pass
|
| 106 |
+
|
| 107 |
+
_ACCESS_LOG_POOL.submit(_write)
|
| 108 |
+
except Exception: # noqa: BLE001 β auditing must never block a response
|
| 109 |
+
pass
|
| 110 |
+
return response
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
# ββ Ops middleware (Phase 5 β api/ops.py: rate limit + metrics + logs) ββββ
|
| 114 |
+
# Registered AFTER access_log_middleware and BEFORE auth_middleware, so the
|
| 115 |
+
# runtime order (last-registered runs first) is:
|
| 116 |
+
# auth β ops β tenant-access-log β CORS β routes
|
| 117 |
+
# i.e. the token-bucket key can include the verified token subject, 401s
|
| 118 |
+
# never reach the limiter, and a 429 short-circuits BEFORE the tenant access
|
| 119 |
+
# log (a flood can't amplify into per-request DB writes). Env knobs:
|
| 120 |
+
# BU_RATE_LIMIT ('120/60'; '0' disables), BU_LOG_JSON=1, BU_METRICS_PUBLIC=0.
|
| 121 |
+
from api import ops as _ops
|
| 122 |
+
app.middleware("http")(_ops.ops_middleware)
|
| 123 |
+
|
| 124 |
# Bearer-token auth on every route except an allowlist (/health, /login,
|
| 125 |
# /docs, /videos, /media, OPTIONS preflight). Configure via env vars
|
| 126 |
# BU_AUTH_USER / BU_AUTH_PASSWORD / BU_AUTH_SECRET. See api/auth.py.
|
| 127 |
from api.auth import auth_middleware, mint_token, check_password
|
| 128 |
app.middleware("http")(auth_middleware)
|
| 129 |
|
| 130 |
+
# /metrics is on the auth allowlist by default: Prometheus scrapers don't
|
| 131 |
+
# hold bearers and the exposition carries no tenant payload (aggregate
|
| 132 |
+
# counts + route templates only β see api/ops.py Β§2 for the tradeoff).
|
| 133 |
+
# Set BU_METRICS_PUBLIC=0 to keep it behind the bearer gate instead.
|
| 134 |
+
if _ops.METRICS_PUBLIC:
|
| 135 |
+
import api.auth as _auth_mod
|
| 136 |
+
_auth_mod.PUBLIC_PATHS.add("/metrics")
|
| 137 |
+
|
| 138 |
+
# ATP (Agent University) routes β /atp/* (see api/atp.py + docs/ATP.md Β§5).
|
| 139 |
+
from api.atp import router as atp_router
|
| 140 |
+
app.include_router(atp_router)
|
| 141 |
+
|
| 142 |
+
# Identity routes (Phase 2 β OIDC code flow, local org accounts, org-scoped
|
| 143 |
+
# JWT sessions; see api/identity.py + docs/TENANCY.md). Guarded import so the
|
| 144 |
+
# legacy single-admin demo keeps booting if this file ships first.
|
| 145 |
+
try:
|
| 146 |
+
from api.identity import router as identity_router
|
| 147 |
+
except ImportError: # pragma: no cover β api/identity.py not deployed yet
|
| 148 |
+
identity_router = None
|
| 149 |
+
import sys as _sys
|
| 150 |
+
print("WARNING: api/identity.py not present β identity routes not "
|
| 151 |
+
"mounted; legacy /login only.", file=_sys.stderr)
|
| 152 |
+
if identity_router is not None:
|
| 153 |
+
app.include_router(identity_router)
|
| 154 |
+
|
| 155 |
+
# Billing + licensed expert delivery (Phase 4 β docs/HARDENING.md; see
|
| 156 |
+
# api/billing.py). Guarded import, same idiom as identity above.
|
| 157 |
+
try:
|
| 158 |
+
from api.billing import experts_router, router as billing_router
|
| 159 |
+
except ImportError: # pragma: no cover β api/billing.py not deployed yet
|
| 160 |
+
billing_router = experts_router = None
|
| 161 |
+
import sys as _sys
|
| 162 |
+
print("WARNING: api/billing.py not present β billing/expert routes not "
|
| 163 |
+
"mounted.", file=_sys.stderr)
|
| 164 |
+
if billing_router is not None:
|
| 165 |
+
app.include_router(billing_router)
|
| 166 |
+
app.include_router(experts_router)
|
| 167 |
+
# Auth-allowlist wiring for the two non-bearer surfaces (api/auth.py owns
|
| 168 |
+
# the sets; these additions live here with the routes they serve):
|
| 169 |
+
# * /billing/webhook β Stripe cannot send a bearer; every delivery is
|
| 170 |
+
# authenticated by its Stripe-Signature header inside the handler.
|
| 171 |
+
# * /experts/* β authenticated per-request by license key
|
| 172 |
+
# (X-ATP-License-Key); the handler hits the DB on EVERY call, so
|
| 173 |
+
# revocation is enforced immediately (bearer optional).
|
| 174 |
+
import api.auth as _auth
|
| 175 |
+
_auth.PUBLIC_PATHS.add("/billing/webhook")
|
| 176 |
+
_auth.PUBLIC_PREFIXES = _auth.PUBLIC_PREFIXES + ("/experts/",)
|
| 177 |
+
|
| 178 |
+
# Company-knowledge ingestion + L5 exam drafting (Phase 6 β
|
| 179 |
+
# docs/HARDENING.md first-customer UX; see api/knowledge.py). Guarded
|
| 180 |
+
# import, same idiom as identity/billing above. Every /knowledge route is
|
| 181 |
+
# bearer-gated (no public-path additions β uploads are T2 crown jewels).
|
| 182 |
+
try:
|
| 183 |
+
from api.knowledge import router as knowledge_router
|
| 184 |
+
except ImportError: # pragma: no cover β api/knowledge.py not deployed yet
|
| 185 |
+
knowledge_router = None
|
| 186 |
+
import sys as _sys
|
| 187 |
+
print("WARNING: api/knowledge.py not present β company-knowledge routes "
|
| 188 |
+
"not mounted.", file=_sys.stderr)
|
| 189 |
+
if knowledge_router is not None:
|
| 190 |
+
app.include_router(knowledge_router)
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def _request_org(request: Request) -> str:
|
| 194 |
+
"""Tenant for this request β ONLY ever from verified auth state
|
| 195 |
+
(request.state.org_id, set by the auth middleware from the JWT; TENANCY.md
|
| 196 |
+
Β§4 layer 1), never from client-supplied values. The 'org-demo' fallback
|
| 197 |
+
covers BU_AUTH_DISABLED dev mode and legacy single-admin tokens."""
|
| 198 |
+
return getattr(request.state, "org_id", None) or "org-demo"
|
| 199 |
+
|
| 200 |
|
| 201 |
class LoginBody(BaseModel):
|
| 202 |
username: str
|
|
|
|
| 222 |
"""Build the merged corpus graph + cluster list once and cache."""
|
| 223 |
if "G" in _GRAPH_CACHE:
|
| 224 |
return _GRAPH_CACHE
|
| 225 |
+
from graph.clusters import build_clusters, load_graph
|
| 226 |
+
from graph.foundations import recommend
|
| 227 |
+
clusters = build_clusters() # cached to data/sessions/clusters.json
|
| 228 |
+
G = load_graph()
|
| 229 |
+
foundations = recommend(limit=8)
|
|
|
|
| 230 |
_GRAPH_CACHE.update(G=G, clusters=clusters, foundations=foundations)
|
| 231 |
return _GRAPH_CACHE
|
| 232 |
|
|
|
|
| 394 |
# ββ Background jobs (async podcast render) ββββββββββββββββββββββββββββββββ
|
| 395 |
|
| 396 |
@app.post("/podcast/render/async")
|
| 397 |
+
def podcast_render_async(body: PodcastRenderBody, request: Request):
|
| 398 |
"""Enqueue a cloud podcast render; returns a job_id to poll at /jobs/{id}."""
|
| 399 |
from agents import jobs, podcast_video
|
| 400 |
|
|
|
|
| 404 |
raise RuntimeError("render failed (check moviepy/gTTS install)")
|
| 405 |
return {k: str(v.relative_to(PROJECT_ROOT)) for k, v in r.items()}
|
| 406 |
|
| 407 |
+
return {"job_id": jobs.submit("podcast_render", task,
|
| 408 |
+
org_id=_request_org(request))}
|
| 409 |
|
| 410 |
|
| 411 |
@app.post("/podcast/local/async")
|
| 412 |
+
def podcast_local_async(body: LocalPodcastBody, request: Request):
|
| 413 |
"""Enqueue a fully-local podcast render; poll at /jobs/{id}."""
|
| 414 |
from agents import jobs
|
| 415 |
from agents.local_podcast import generate_local
|
|
|
|
| 421 |
raise RuntimeError("local podcast generation failed")
|
| 422 |
return {"mp3": str(p.relative_to(PROJECT_ROOT))}
|
| 423 |
|
| 424 |
+
return {"job_id": jobs.submit("podcast_local", task,
|
| 425 |
+
org_id=_request_org(request))}
|
| 426 |
|
| 427 |
|
| 428 |
@app.get("/jobs/{job_id}")
|
| 429 |
+
def job_status(job_id: str, request: Request):
|
| 430 |
+
"""Job status, scoped to the caller's org (jobs.get only sees the org's
|
| 431 |
+
own rows β RLS-backed on Postgres). A cross-tenant id 404s with the SAME
|
| 432 |
+
message as an unknown id β no existence oracle (TENANCY.md #6)."""
|
| 433 |
from agents import jobs
|
| 434 |
+
j = jobs.get(job_id, org_id=_request_org(request))
|
| 435 |
if not j:
|
| 436 |
raise HTTPException(404, f"no such job {job_id}")
|
| 437 |
return j
|
|
|
|
| 442 |
return {"ok": True}
|
| 443 |
|
| 444 |
|
| 445 |
+
@app.get("/metrics")
|
| 446 |
+
def metrics():
|
| 447 |
+
"""Prometheus text exposition β hand-rolled, bounded label set (Phase 5,
|
| 448 |
+
api/ops.py). Public by default (BU_METRICS_PUBLIC=0 gates it behind
|
| 449 |
+
auth); exempt from rate limiting so scrapers never see a 429."""
|
| 450 |
+
return _ops.metrics_response()
|
| 451 |
+
|
| 452 |
+
|
| 453 |
@app.get("/graph")
|
| 454 |
def graph_endpoint():
|
| 455 |
g = _graph()["G"]
|
|
|
|
| 469 |
|
| 470 |
def _foundation_card(f) -> dict:
|
| 471 |
return {
|
| 472 |
+
"node": getattr(f, "raw_name", None) or getattr(f, "name", str(f)),
|
| 473 |
"score": getattr(f, "score", None),
|
| 474 |
"rationale": getattr(f, "rationale", []),
|
| 475 |
"cluster_id": getattr(f, "cluster_id", None),
|
|
|
|
| 604 |
@app.post("/socratic/start")
|
| 605 |
def socratic_start(body: SocraticStartBody):
|
| 606 |
from agents.socratic import start_session
|
| 607 |
+
from agents.persistence import save_socratic_session
|
| 608 |
import uuid
|
| 609 |
state = start_session(body.node)
|
| 610 |
sid = f"soc_{uuid.uuid4().hex[:12]}"
|
| 611 |
+
save_socratic_session(sid, body.user_id, body.node, state)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 612 |
return {"session_id": sid, "state": state}
|
| 613 |
|
| 614 |
|
| 615 |
@app.post("/socratic/step")
|
| 616 |
def socratic_step(body: SocraticStepBody):
|
| 617 |
from agents.socratic import step
|
| 618 |
+
from agents.persistence import (load_socratic_session,
|
| 619 |
+
update_socratic_session)
|
| 620 |
+
session = load_socratic_session(body.session_id)
|
| 621 |
+
if not session:
|
|
|
|
|
|
|
|
|
|
| 622 |
raise HTTPException(404, "session not found")
|
| 623 |
+
new_state = step(session["state"], body.answer)
|
| 624 |
+
update_socratic_session(body.session_id, new_state)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 625 |
return new_state
|
| 626 |
|
| 627 |
|