Migrate admin endpoints from token-in-URL to X-Admin-Token header

This commit is contained in:
inference-bot committed 2026-09-14 18:05:58 -06:00
1 parent fabd5a37ae
commit 1a3c6027bd
2 files changed
+48 -40

No files matched your search

+43 -38
View File
@@ -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
+5 -2
View File
@@ -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())