Security cleanup: remove dead OPENCOLLECTIVE_SECRET + migrate-teams endpoint + unused helpers; default firstPayment=False
This commit is contained in:
1 parent
1a3c6027bd
commit
505f3d9f48
1 file changed
+4
-86
+4
-86
@@ -38,7 +38,6 @@ CLOUDRON_API = os.environ.get("CLOUDRON_API_ORIGIN", "https://my.inference.coop"
|
|||||||
CLOUDRON_TOKEN = os.environ.get("CLOUDRON_TOKEN", "")
|
CLOUDRON_TOKEN = os.environ.get("CLOUDRON_TOKEN", "")
|
||||||
LITELLM_BASE = os.environ.get("LITELLM_BASE", "https://gateway.inference.coop")
|
LITELLM_BASE = os.environ.get("LITELLM_BASE", "https://gateway.inference.coop")
|
||||||
LITELLM_MASTER_KEY = os.environ.get("LITELLM_MASTER_KEY", "")
|
LITELLM_MASTER_KEY = os.environ.get("LITELLM_MASTER_KEY", "")
|
||||||
OPENCOLLECTIVE_SECRET = os.environ.get("OPENCOLLECTIVE_WEBHOOK_SECRET", "")
|
|
||||||
|
|
||||||
# Shared secret that the chat frontend uses to authenticate requests to the
|
# Shared secret that the chat frontend uses to authenticate requests to the
|
||||||
# portal. LibreChat sends it as an X-Portal-Secret header; OpenWebUI signs the
|
# portal. LibreChat sends it as an X-Portal-Secret header; OpenWebUI signs the
|
||||||
@@ -425,21 +424,6 @@ def store_member(email: str, key_token: str, cloudron_user_id: str, slug: str =
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def rekey_member(email: str, key_token: str) -> None:
|
|
||||||
"""Update only the key token, preserving cloudron_user_id and slug.
|
|
||||||
|
|
||||||
Used by the Teams migration to swap in a fresh sk- key without clobbering
|
|
||||||
the member's existing Cloudron identity.
|
|
||||||
"""
|
|
||||||
conn = get_db()
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE members SET key_token = ?, active = 1 WHERE email = ?",
|
|
||||||
(key_token, email),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
def get_member_key(email: str) -> str | None:
|
def get_member_key(email: str) -> str | None:
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
@@ -833,8 +817,10 @@ async def opencollective_webhook(request: Request, token: str):
|
|||||||
# New member → provision immediately and send our own welcome email.
|
# New member → provision immediately and send our own welcome email.
|
||||||
# The webhook strips email, so look it up by slug via the admin token.
|
# The webhook strips email, so look it up by slug via the admin token.
|
||||||
# Only provision on firstPayment (order.processed also fires on monthly
|
# Only provision on firstPayment (order.processed also fires on monthly
|
||||||
# renewals, which should NOT re-send the welcome email).
|
# renewals, which should NOT re-send the welcome email). Default to
|
||||||
first_payment = data.get("firstPayment", True)
|
# False so a missing field never triggers a spurious re-provision; the
|
||||||
|
# nightly sweep backstops any missed first payment.
|
||||||
|
first_payment = data.get("firstPayment", False)
|
||||||
if slug and first_payment:
|
if slug and first_payment:
|
||||||
members = await fetch_members()
|
members = await fetch_members()
|
||||||
for m in members:
|
for m in members:
|
||||||
@@ -1052,41 +1038,6 @@ async def admin_set_balance(request: Request):
|
|||||||
return {"status": "updated", "email": email, "balance": new_balance, "team_budget": applied}
|
return {"status": "updated", "email": email, "balance": new_balance, "team_budget": applied}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/admin/migrate-teams")
|
|
||||||
async def admin_migrate_teams(request: Request):
|
|
||||||
"""One-off migration: put every active member under a LiteLLM team + fresh
|
|
||||||
sk- key. Fixes a bug where some members had SHA256 hashes (from /key/list)
|
|
||||||
stored as their key, which cannot authenticate.
|
|
||||||
|
|
||||||
For each active member: delete any existing key(s) by alias, create/reuse a
|
|
||||||
team, generate a fresh sk- key under it, and store the sk- token. Idempotent
|
|
||||||
but re-issues keys, so call once.
|
|
||||||
"""
|
|
||||||
_verify_admin_token(request)
|
|
||||||
|
|
||||||
conn = get_db()
|
|
||||||
rows = conn.execute("SELECT email, key_token FROM members WHERE active = 1").fetchall()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
results = []
|
|
||||||
for email, old_token in rows:
|
|
||||||
try:
|
|
||||||
# Delete any existing key(s) for this alias (by hash or sk-).
|
|
||||||
await litellm_delete_keys_by_alias(email)
|
|
||||||
# (Re)create the team and a fresh sk- key, at the member's balance.
|
|
||||||
balance = get_member_balance(email)
|
|
||||||
team_id = await litellm_get_or_create_team(email, balance)
|
|
||||||
new_key = await litellm_create_key(email, balance, team_id)
|
|
||||||
rekey_member(email, new_key) # preserve cloudron_user_id + slug
|
|
||||||
results.append({"email": email, "status": "rekeyed"})
|
|
||||||
logger.info("Migrated %s to team %s", email, team_id)
|
|
||||||
except Exception as e:
|
|
||||||
results.append({"email": email, "status": "error", "error": str(e)})
|
|
||||||
logger.warning("Migrate failed for %s: %s", email, e)
|
|
||||||
|
|
||||||
return {"status": "migrated", "results": results}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/admin/overview")
|
@app.get("/admin/overview")
|
||||||
async def admin_overview(request: Request):
|
async def admin_overview(request: Request):
|
||||||
"""Return a co-op-wide overview for the admin dashboard.
|
"""Return a co-op-wide overview for the admin dashboard.
|
||||||
@@ -1168,39 +1119,6 @@ async def admin_overview(request: Request):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def litellm_delete_keys_by_alias(email: str) -> None:
|
|
||||||
"""Delete all LiteLLM keys whose alias matches member:<email>.
|
|
||||||
|
|
||||||
/key/list returns SHA256 hashes; /key/delete accepts hashes. This clears
|
|
||||||
both legacy hash-keyed and current sk- keyed entries for a member.
|
|
||||||
"""
|
|
||||||
alias = f"member:{email}"
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
r = await client.get(
|
|
||||||
f"{LITELLM_BASE}/key/list",
|
|
||||||
headers={"Authorization": f"Bearer {LITELLM_MASTER_KEY}"},
|
|
||||||
)
|
|
||||||
r.raise_for_status()
|
|
||||||
hashes = r.json().get("keys", [])
|
|
||||||
to_delete = []
|
|
||||||
for h in hashes:
|
|
||||||
info = await client.get(
|
|
||||||
f"{LITELLM_BASE}/key/info",
|
|
||||||
headers={"Authorization": f"Bearer {LITELLM_MASTER_KEY}"},
|
|
||||||
params={"key": h},
|
|
||||||
)
|
|
||||||
if info.status_code == 200:
|
|
||||||
data = info.json().get("info", {})
|
|
||||||
if data.get("key_alias") == alias:
|
|
||||||
to_delete.append(h)
|
|
||||||
if to_delete:
|
|
||||||
await client.post(
|
|
||||||
f"{LITELLM_BASE}/key/delete",
|
|
||||||
headers={"Authorization": f"Bearer {LITELLM_MASTER_KEY}"},
|
|
||||||
json={"keys": to_delete},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# --- Broker HTTP endpoints (called by the member dashboard) ---
|
# --- Broker HTTP endpoints (called by the member dashboard) ---
|
||||||
# The dashboard is Cloudron-SSO-gated; it authenticates the member and passes
|
# 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
|
# their email with a shared secret. The portal trusts the email only because it
|
||||||
|
|||||||
Reference in new issue
Block a user