Pass raw identity (username) to broker; portal resolves to email

This commit is contained in:
inference-bot committed 2026-09-14 12:59:51 -06:00
1 parent 5264c3e9f7
commit 105ff499f5
1 file changed
+34 -45
+34 -45
View File
@@ -30,52 +30,43 @@ app = FastAPI()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Identity — Cloudron proxyAuth injects the authenticated user's email. # Identity — Cloudron proxyAuth injects the authenticated user's USERNAME.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def get_user_email(request: Request) -> str: def get_user_identity(request: Request) -> str:
"""Return the logged-in user's email from Cloudron's proxyAuth headers. """Return the logged-in user's identity (Cloudron username, or email if
Cloudron happens to send one).
Cloudron's nginx auth-request module injects X-Forwarded-User. We also Cloudron's proxyAuth injects the USERNAME (e.g. "ntnsndr") via
accept X-Remote-User and X-Auth-Request-Email as fallbacks, since the exact X-Remote-User, not the email. We pass it through unchanged to the broker,
header set has varied across Cloudron versions. which resolves username → email (it holds the Cloudron admin token).
""" """
for header in ( for header in (
"x-forwarded-user",
"x-remote-user", "x-remote-user",
"x-forwarded-user",
"x-auth-request-user", "x-auth-request-user",
"x-auth-request-email", "x-auth-request-email",
"x-forwarded-email", "x-forwarded-email",
): ):
val = request.headers.get(header) val = request.headers.get(header)
if val: if val:
val = val.strip().lower() return val.strip()
# Some proxies prefix "user:" or use a bare username; emails are the
# canonical identity here.
if "@" in val:
return val
logger.warning("Header %s had no email (@): %r", header, val)
# Last resort: Cloudron also sets x-auth-request-email.
logger.warning("No identity header found; user unknown")
return "" return ""
def is_trusted(request: Request) -> bool:
"""Only trust identity headers from Cloudron's proxy (behind us)."""
# Cloudron injects these headers itself; we trust them because the app is
# only reachable through Cloudron's proxy. This is a defense-in-depth note;
# the app is not directly exposed.
return True
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Broker calls (to the member portal) # Broker calls (to the member portal)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def broker_get(email: str, path: str) -> dict: def broker_headers(identity: str) -> dict:
headers = {"X-Broker-Secret": BROKER_SECRET, "X-Member-Email": email} # Pass the raw identity (username or email); the portal resolves it to an
# email via its own Cloudron user list.
return {"X-Broker-Secret": BROKER_SECRET, "X-Member-User": identity}
async def broker_get(identity: str, path: str) -> dict:
async with httpx.AsyncClient(timeout=15.0) as client: async with httpx.AsyncClient(timeout=15.0) as client:
r = await client.get(f"{PORTAL_BASE}{path}", headers=headers) r = await client.get(f"{PORTAL_BASE}{path}", headers=broker_headers(identity))
if r.status_code == 401: if r.status_code == 401:
raise HTTPException(500, "Portal rejected broker credentials") raise HTTPException(500, "Portal rejected broker credentials")
if r.status_code == 403: if r.status_code == 403:
@@ -85,10 +76,9 @@ async def broker_get(email: str, path: str) -> dict:
return r.json() return r.json()
async def broker_post(email: str, path: str, payload: dict | None = None) -> dict: async def broker_post(identity: str, path: str, payload: dict | None = None) -> dict:
headers = {"X-Broker-Secret": BROKER_SECRET, "X-Member-Email": email}
async with httpx.AsyncClient(timeout=15.0) as client: async with httpx.AsyncClient(timeout=15.0) as client:
r = await client.post(f"{PORTAL_BASE}{path}", headers=headers, json=payload or {}) r = await client.post(f"{PORTAL_BASE}{path}", headers=broker_headers(identity), json=payload or {})
if r.status_code in (401, 403): if r.status_code in (401, 403):
raise HTTPException(r.status_code, r.text) raise HTTPException(r.status_code, r.text)
if r.status_code not in (200, 201): if r.status_code not in (200, 201):
@@ -96,10 +86,9 @@ async def broker_post(email: str, path: str, payload: dict | None = None) -> dic
return r.json() return r.json()
async def broker_delete(email: str, path: str) -> dict: async def broker_delete(identity: str, path: str) -> dict:
headers = {"X-Broker-Secret": BROKER_SECRET, "X-Member-Email": email}
async with httpx.AsyncClient(timeout=15.0) as client: async with httpx.AsyncClient(timeout=15.0) as client:
r = await client.delete(f"{PORTAL_BASE}{path}", headers=headers) r = await client.delete(f"{PORTAL_BASE}{path}", headers=broker_headers(identity))
if r.status_code in (401, 403): if r.status_code in (401, 403):
raise HTTPException(r.status_code, r.text) raise HTTPException(r.status_code, r.text)
if r.status_code not in (200, 204): if r.status_code not in (200, 204):
@@ -118,23 +107,23 @@ async def healthz():
@app.get("/") @app.get("/")
async def index(request: Request): async def index(request: Request):
email = get_user_email(request) identity = get_user_identity(request)
if not email: if not identity:
return HTMLResponse( return HTMLResponse(
"<h1>Not authenticated</h1><p>Please log in via the dashboard login.</p>", "<h1>Not authenticated</h1><p>Please log in via the dashboard login.</p>",
status_code=401, status_code=401,
) )
return HTMLResponse(render_page(email)) return HTMLResponse(render_page(identity))
@app.get("/api/usage") @app.get("/api/usage")
async def api_usage(request: Request): async def api_usage(request: Request):
email = get_user_email(request) identity = get_user_identity(request)
if not email: if not identity:
return JSONResponse({"error": "unauthenticated"}, status_code=401) return JSONResponse({"error": "unauthenticated"}, status_code=401)
try: try:
data = await broker_get(email, "/broker/usage") data = await broker_get(identity, "/broker/usage")
keys = await broker_get(email, "/broker/keys") keys = await broker_get(identity, "/broker/keys")
data["keys"] = keys.get("keys", []) data["keys"] = keys.get("keys", [])
return data return data
except HTTPException as e: except HTTPException as e:
@@ -143,26 +132,26 @@ async def api_usage(request: Request):
@app.post("/api/keys") @app.post("/api/keys")
async def api_create_key(request: Request): async def api_create_key(request: Request):
email = get_user_email(request) identity = get_user_identity(request)
if not email: if not identity:
return JSONResponse({"error": "unauthenticated"}, status_code=401) return JSONResponse({"error": "unauthenticated"}, status_code=401)
body = await request.json() body = await request.json()
name = (body.get("name") or "").strip() name = (body.get("name") or "").strip()
if not re.fullmatch(r"[A-Za-z0-9._-]{1,64}", name): if not re.fullmatch(r"[A-Za-z0-9._-]{1,64}", name):
return JSONResponse({"error": "Invalid key name"}, status_code=400) return JSONResponse({"error": "Invalid key name"}, status_code=400)
try: try:
return await broker_post(email, "/broker/keys", {"name": name}) return await broker_post(identity, "/broker/keys", {"name": name})
except HTTPException as e: except HTTPException as e:
return JSONResponse({"error": e.detail}, status_code=e.status_code) return JSONResponse({"error": e.detail}, status_code=e.status_code)
@app.delete("/api/keys/{name}") @app.delete("/api/keys/{name}")
async def api_revoke_key(name: str, request: Request): async def api_revoke_key(name: str, request: Request):
email = get_user_email(request) identity = get_user_identity(request)
if not email: if not identity:
return JSONResponse({"error": "unauthenticated"}, status_code=401) return JSONResponse({"error": "unauthenticated"}, status_code=401)
try: try:
return await broker_delete(email, f"/broker/keys/{name}") return await broker_delete(identity, f"/broker/keys/{name}")
except HTTPException as e: except HTTPException as e:
return JSONResponse({"error": e.detail}, status_code=e.status_code) return JSONResponse({"error": e.detail}, status_code=e.status_code)