From 1a3c6027bd0e5fd0b5bbb53b32c442cbc3750c5e Mon Sep 17 00:00:00 2001 From: inference-bot Date: Mon, 14 Sep 2026 18:05:58 -0600 Subject: [PATCH] Migrate admin endpoints from token-in-URL to X-Admin-Token header --- app/main.py | 81 ++++++++++++++++++++++++++++------------------------- sweep.sh | 7 +++-- 2 files changed, 48 insertions(+), 40 deletions(-) diff --git a/app/main.py b/app/main.py index 4c0a6af..c3a736b 100644 --- a/app/main.py +++ b/app/main.py @@ -138,6 +138,19 @@ def _verify_portal_secret(request: Request) -> None: raise HTTPException(401, "Invalid portal secret") +def _verify_admin_token(request: Request) -> None: + """Authenticate admin/internal endpoints via the X-Admin-Token header. + + Replaces the old token-in-URL pattern (tokens in URLs leak into access + logs and Referer headers). Callers pass the admin token in a header instead. + """ + if not WEBHOOK_TOKEN: + raise HTTPException(503, "Admin token not configured") + provided = request.headers.get("x-admin-token", "") + if not secrets.compare_digest(provided, WEBHOOK_TOKEN): + raise HTTPException(401, "Invalid admin token") + + def get_db() -> sqlite3.Connection: conn = sqlite3.connect(DB_PATH) conn.execute( @@ -971,33 +984,29 @@ async def health(): return {"status": "ok"} -@app.post("/admin/sync-loomio/{token}") -async def admin_sync_loomio(token: str): +@app.post("/admin/sync-loomio") +async def admin_sync_loomio(request: Request): """Manually trigger a Loomio membership sync. - Protected by the same secret token as the Open Collective webhook. Useful - for reconciling pre-existing members (accounts created before the sync - existed) or recovering from a missed webhook. Idempotent — safe to call - repeatedly. + Authenticated by the X-Admin-Token header. Useful for reconciling + pre-existing members (accounts created before the sync existed) or + recovering from a missed webhook. Idempotent — safe to call repeatedly. """ - if not WEBHOOK_TOKEN or not secrets.compare_digest(token, WEBHOOK_TOKEN): - raise HTTPException(401, "Invalid token") + _verify_admin_token(request) await sync_loomio_memberships() return {"status": "synced"} -@app.post("/admin/provision/{token}") -async def admin_provision(token: str, request: Request): +@app.post("/admin/provision") +async def admin_provision(request: Request): """Manually provision a member by email (full pipeline). - Protected by the same secret token as the webhook. Runs the full - provisioning pipeline: Cloudron user + members group + LiteLLM key + our - welcome email + Loomio sync. Body: {"email": "...", "name": "..."}. - Idempotent — safe to call repeatedly. + Authenticated by the X-Admin-Token header. Runs the full provisioning + pipeline: Cloudron user + members group + LiteLLM key + our welcome email + + Loomio sync. Body: {"email": "...", "name": "..."}. Idempotent. """ - if not WEBHOOK_TOKEN or not secrets.compare_digest(token, WEBHOOK_TOKEN): - raise HTTPException(401, "Invalid token") + _verify_admin_token(request) body = await request.json() email = (body.get("email") or "").strip().lower() @@ -1009,8 +1018,8 @@ async def admin_provision(token: str, request: Request): return {"status": "provisioned", "email": email, "user_id": user_id} -@app.post("/admin/set-balance/{token}") -async def admin_set_balance(token: str, request: Request): +@app.post("/admin/set-balance") +async def admin_set_balance(request: Request): """Set (or add to) a member's credit balance and sync it to LiteLLM. The flexible alternative to a fixed $15/month allowance. A payment (from OC @@ -1020,8 +1029,7 @@ async def admin_set_balance(token: str, request: Request): Body: {"email": "...", "balance": 25.0} → set absolute balance {"email": "...", "add": 10.0} → add to current balance """ - if not WEBHOOK_TOKEN or not secrets.compare_digest(token, WEBHOOK_TOKEN): - raise HTTPException(401, "Invalid token") + _verify_admin_token(request) body = await request.json() email = (body.get("email") or "").strip().lower() @@ -1044,8 +1052,8 @@ async def admin_set_balance(token: str, request: Request): return {"status": "updated", "email": email, "balance": new_balance, "team_budget": applied} -@app.post("/admin/migrate-teams/{token}") -async def admin_migrate_teams(token: str): +@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. @@ -1054,8 +1062,7 @@ async def admin_migrate_teams(token: str): team, generate a fresh sk- key under it, and store the sk- token. Idempotent but re-issues keys, so call once. """ - if not WEBHOOK_TOKEN or not secrets.compare_digest(token, WEBHOOK_TOKEN): - raise HTTPException(401, "Invalid token") + _verify_admin_token(request) conn = get_db() rows = conn.execute("SELECT email, key_token FROM members WHERE active = 1").fetchall() @@ -1080,16 +1087,15 @@ async def admin_migrate_teams(token: str): return {"status": "migrated", "results": results} -@app.get("/admin/overview/{token}") -async def admin_overview(token: str): +@app.get("/admin/overview") +async def admin_overview(request: Request): """Return a co-op-wide overview for the admin dashboard. - Protected by the same secret token as the other /admin endpoints. Returns - every member (active + inactive) with their balance, spend, remaining, and - team reset date, plus co-op aggregates (total members, total spend). + Authenticated by the X-Admin-Token header. Returns every member (active + + inactive) with their balance, spend, remaining, and team reset date, plus + co-op aggregates (total members, total spend). """ - if not WEBHOOK_TOKEN or not secrets.compare_digest(token, WEBHOOK_TOKEN): - raise HTTPException(401, "Invalid token") + _verify_admin_token(request) conn = get_db() rows = conn.execute( @@ -1421,16 +1427,15 @@ async def reconcile_memberships() -> dict: } -@app.post("/admin/sweep/{token}") -async def admin_sweep(token: str): +@app.post("/admin/sweep") +async def admin_sweep(request: Request): """Manually trigger a full membership reconciliation. - Protected by the same secret token as the webhook. Idempotent — safe to - call repeatedly. This is also what the Cloudron scheduler invokes on a - cron schedule (see the sweep script). + Authenticated by the X-Admin-Token header. Idempotent — safe to call + repeatedly. This is also what the Cloudron scheduler invokes on a cron + schedule (see the sweep script). """ - if not WEBHOOK_TOKEN or not secrets.compare_digest(token, WEBHOOK_TOKEN): - raise HTTPException(401, "Invalid token") + _verify_admin_token(request) result = await reconcile_memberships() return result diff --git a/sweep.sh b/sweep.sh index 17c2d08..191b7ef 100644 --- a/sweep.sh +++ b/sweep.sh @@ -21,8 +21,11 @@ python3 - "$WEBHOOK_TOKEN" <<'PY' import sys, urllib.request, urllib.error token = sys.argv[1] -url = f"http://127.0.0.1:8000/admin/sweep/{token}" -req = urllib.request.Request(url, method="POST", data=b"", headers={"Content-Type": "application/json"}) +url = "http://127.0.0.1:8000/admin/sweep" +req = urllib.request.Request(url, method="POST", data=b"", headers={ + "Content-Type": "application/json", + "X-Admin-Token": token, +}) try: with urllib.request.urlopen(req, timeout=60) as resp: print("sweep:", resp.read().decode())