Add broker: member-managed API keys (create/list/revoke) scoped to team, BROKER_SECRET-authenticated
This commit is contained in:
1 parent
2ac821087e
commit
b0c68c5dda
1 file changed
+145
+145
@@ -16,6 +16,7 @@ Three responsibilities:
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import secrets
|
import secrets
|
||||||
import smtplib
|
import smtplib
|
||||||
@@ -51,6 +52,12 @@ PORTAL_SECRET = os.environ.get("PORTAL_SECRET", "")
|
|||||||
# email itself is tamper-proof.
|
# email itself is tamper-proof.
|
||||||
OWUI_JWT_SECRET = os.environ.get("OWUI_JWT_SECRET", "")
|
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
|
# 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
|
# Collective's generic webhooks are not HMAC-signed, so a secret in the URL
|
||||||
# is the standard way to authenticate them.
|
# is the standard way to authenticate them.
|
||||||
@@ -142,6 +149,19 @@ def get_db() -> sqlite3.Connection:
|
|||||||
"active INTEGER DEFAULT 1"
|
"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.
|
# Migration: add slug column if the members table predates it.
|
||||||
cols = [r[1] for r in conn.execute("PRAGMA table_info(members)").fetchall()]
|
cols = [r[1] for r in conn.execute("PRAGMA table_info(members)").fetchall()]
|
||||||
if "slug" not in cols:
|
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 ---
|
# --- A. Open Collective webhook ---
|
||||||
|
|
||||||
@app.post("/webhook/opencollective/{token}")
|
@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:
|
async def reconcile_memberships() -> dict:
|
||||||
"""Reconcile the portal against the live Open Collective member list.
|
"""Reconcile the portal against the live Open Collective member list.
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user