diff --git a/app/main.py b/app/main.py index cdb1a48..1c29874 100644 --- a/app/main.py +++ b/app/main.py @@ -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.