Migrate admin endpoints from token-in-URL to X-Admin-Token header
This commit is contained in:
1 parent
fabd5a37ae
commit
1a3c6027bd
2 files changed
+48
-40
No files matched your search
+43
-38
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in new issue
Block a user