From 3024bfec748355eef51f8dcf56b5b414ef3cea18 Mon Sep 17 00:00:00 2001 From: inference-bot Date: Mon, 14 Sep 2026 12:59:42 -0600 Subject: [PATCH] Broker: resolve username->email via Cloudron user list (proxyAuth injects username, not email) --- app/main.py | 47 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/app/main.py b/app/main.py index f1e1df7..f37604a 100644 --- a/app/main.py +++ b/app/main.py @@ -1102,23 +1102,60 @@ async def litellm_delete_keys_by_alias(email: str) -> None: def _verify_broker_secret(request: Request) -> str: """Return the authenticated member's email, or 401. - The member dashboard sends X-Broker-Secret (shared secret) + X-Member-Email. - Both must be present and the secret must match, or we refuse (fail closed). + The member dashboard sends X-Broker-Secret (shared secret) + X-Member-Email + (or X-Member-User, a Cloudron username). Both must be present and the secret + must match, or we refuse (fail closed). + + Cloudron's proxyAuth injects the USERNAME (e.g. "ntnsndr"), not the email. + So when the dashboard sends a username (no "@"), we resolve it to an email + via the Cloudron user list (we already hold a Cloudron admin token). """ if not BROKER_SECRET: raise HTTPException(503, "Broker secret not configured") provided = request.headers.get("x-broker-secret", "") if not secrets.compare_digest(provided, BROKER_SECRET): raise HTTPException(401, "Invalid broker secret") - email = (request.headers.get("x-member-email") or "").strip().lower() - if not email: - raise HTTPException(401, "Missing member email") + identity = ( + request.headers.get("x-member-email") + or request.headers.get("x-member-user") + or "" + ).strip() + if not identity: + raise HTTPException(401, "Missing member identity") + email = resolve_member_email(identity) # The member must be active in our DB before they can manage keys. if not get_member_key(email): raise HTTPException(403, "No active membership") return email +def resolve_member_email(identity: str) -> str: + """Resolve a member identity (email OR Cloudron username) to their email. + + If it looks like an email (contains "@"), return it lowercased. Otherwise + treat it as a Cloudron username and look up the corresponding email from + the Cloudron user list (synchronous, using urllib since this runs outside + the async request path of httpx). + """ + identity = identity.strip() + if "@" in identity: + return identity.lower() + # Username → email via Cloudron user list. + import urllib.request + + req = urllib.request.Request( + f"{CLOUDRON_API}/api/v1/users", + headers={"Authorization": f"Bearer {CLOUDRON_TOKEN}"}, + ) + with urllib.request.urlopen(req, timeout=15) as r: + data = json.loads(r.read().decode()) + users = data.get("users", []) + for u in users: + if u.get("username") == identity: + return (u.get("email") or "").lower() + raise HTTPException(403, f"No Cloudron user for identity {identity!r}") + + @app.get("/broker/keys") async def broker_list(request: Request): email = _verify_broker_secret(request)