Add broker: member-managed API keys (create/list/revoke) scoped to team, BROKER_SECRET-authenticated

This commit is contained in:
inference-bot committed 2026-09-14 09:43:38 -06:00
1 parent 2ac821087e
commit b0c68c5dda
1 file changed
+145
+145
View File
@@ -16,6 +16,7 @@ Three responsibilities:
import os
import json
import logging
import re
import sqlite3
import secrets
import smtplib
@@ -51,6 +52,12 @@ PORTAL_SECRET = os.environ.get("PORTAL_SECRET", "")
# email itself is tamper-proof.
OWUI_JWT_SECRET = os.environ.get("OWUI_JWT_SECRET", "")
# Shared secret that the member dashboard uses to authenticate broker requests.
# The dashboard is Cloudron-SSO-gated and passes the authenticated member's
# email; the portal trusts that email only because it carries this secret. Kept
# separate from PORTAL_SECRET so the two can be rotated independently.
BROKER_SECRET = os.environ.get("BROKER_SECRET", "")
# Secret token required in the Open Collective webhook URL path. Open
# Collective's generic webhooks are not HMAC-signed, so a secret in the URL
# is the standard way to authenticate them.
@@ -142,6 +149,19 @@ def get_db() -> sqlite3.Connection:
"active INTEGER DEFAULT 1"
")"
)
# Member-managed API keys (created via the broker). Each row is one API key
# under the member's team, tracked so we can list/revoke by name without
# exposing the chat key. `sk_token` is the full sk- key (revealed once).
conn.execute(
"CREATE TABLE IF NOT EXISTS member_api_keys ("
"email TEXT NOT NULL, "
"name TEXT NOT NULL, "
"sk_token TEXT NOT NULL, "
"hash TEXT NOT NULL, "
"created_at TEXT DEFAULT (datetime('now')), "
"PRIMARY KEY (email, name)"
")"
)
# Migration: add slug column if the members table predates it.
cols = [r[1] for r in conn.execute("PRAGMA table_info(members)").fetchall()]
if "slug" not in cols:
@@ -616,6 +636,82 @@ async def litellm_disable_key(key_token: str) -> None:
)
# --- Broker: member-managed API keys (scoped to the member's team) ---
def _api_key_alias(email: str, name: str) -> str:
return f"api:{email}:{name}"
async def broker_create_api_key(email: str, name: str) -> dict:
"""Create a member-managed API key under the member's team.
Returns {name, sk_token, created_at}. The key draws from the member's team
budget and is tracked in member_api_keys so it can be listed/revoked by name.
"""
if not name or not re.fullmatch(r"[A-Za-z0-9._-]{1,64}", name):
raise HTTPException(400, "Invalid key name (1-64 chars, alphanumeric/._-)")
team_id = await litellm_get_or_create_team(email, MEMBER_BUDGET)
alias = _api_key_alias(email, name)
async with httpx.AsyncClient() as client:
r = await client.post(
f"{LITELLM_BASE}/key/generate",
headers={"Authorization": f"Bearer {LITELLM_MASTER_KEY}"},
json={
"key_alias": alias,
"team_id": team_id,
"models": MEMBER_MODELS,
},
)
r.raise_for_status()
data = r.json()
sk_token = data.get("key", "")
h = data.get("token") or data.get("token_id") or ""
conn = get_db()
conn.execute(
"INSERT INTO member_api_keys (email, name, sk_token, hash) VALUES (?, ?, ?, ?) "
"ON CONFLICT(email, name) DO UPDATE SET sk_token=excluded.sk_token, hash=excluded.hash, "
"created_at=datetime('now')",
(email, name, sk_token, h),
)
conn.commit()
row = conn.execute(
"SELECT created_at FROM member_api_keys WHERE email = ? AND name = ?",
(email, name),
).fetchone()
conn.close()
return {"name": name, "sk_token": sk_token, "created_at": row[0] if row else None}
async def broker_list_api_keys(email: str) -> list[dict]:
conn = get_db()
rows = conn.execute(
"SELECT name, sk_token, created_at FROM member_api_keys WHERE email = ? ORDER BY created_at DESC",
(email,),
).fetchall()
conn.close()
return [
{"name": r[0], "sk_token": r[1], "created_at": r[2]}
for r in rows
]
async def broker_revoke_api_key(email: str, name: str) -> None:
conn = get_db()
row = conn.execute(
"SELECT hash FROM member_api_keys WHERE email = ? AND name = ?", (email, name)
).fetchone()
if not row:
conn.close()
raise HTTPException(404, "Key not found")
conn.execute("DELETE FROM member_api_keys WHERE email = ? AND name = ?", (email, name))
conn.commit()
conn.close()
# Delete the underlying LiteLLM key by its hash.
await litellm_disable_key(row[0])
# --- A. Open Collective webhook ---
@app.post("/webhook/opencollective/{token}")
@@ -892,6 +988,55 @@ async def litellm_delete_keys_by_alias(email: str) -> None:
)
# --- Broker HTTP endpoints (called by the member dashboard) ---
# The dashboard is Cloudron-SSO-gated; it authenticates the member and passes
# their email with a shared secret. The portal trusts the email only because it
# carries BROKER_SECRET. These endpoints expose ONLY the authenticated member's
# own keys — never another member's, and never the chat key.
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).
"""
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")
# 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
@app.get("/broker/keys")
async def broker_list(request: Request):
email = _verify_broker_secret(request)
return {"keys": await broker_list_api_keys(email)}
@app.post("/broker/keys")
async def broker_create(request: Request):
email = _verify_broker_secret(request)
body = await request.json()
name = (body.get("name") or "").strip()
key = await broker_create_api_key(email, name)
return {"status": "created", **key}
@app.delete("/broker/keys/{name}")
async def broker_revoke(name: str, request: Request):
email = _verify_broker_secret(request)
await broker_revoke_api_key(email, name)
return {"status": "revoked", "name": name}
async def reconcile_memberships() -> dict:
"""Reconcile the portal against the live Open Collective member list.