"""Admin Panel — co-op-wide member overview, spend, and balance management. Security model: - Authentication is done by Cloudron's proxyAuth wall (SSO). Cloudron injects the authenticated user's username via X-Remote-User. - Access is further restricted to a small ADMIN_USERNAMES allowlist (checked against the injected username), so only designated admins see this panel. - This app calls the member portal's /admin/overview and /admin/set-balance endpoints, authenticated with the portal's WEBHOOK_TOKEN (ADMIN_TOKEN). Environment (Cloudron env vars): PORTAL_BASE — base URL of the member portal ADMIN_TOKEN — the portal's admin/webhook token (for /admin/* endpoints) ADMIN_USERNAMES — comma-separated list of allowed Cloudron usernames """ import os import logging import httpx from fastapi import FastAPI, Request, HTTPException from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from app import oidc logging.basicConfig(level=logging.INFO) logger = logging.getLogger("admin-panel") PORTAL_BASE = os.environ.get("PORTAL_BASE", "").rstrip("/") ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "") ADMIN_USERNAMES = { u.strip().lower() for u in os.environ.get("ADMIN_USERNAMES", "").split(",") if u.strip() } app = FastAPI() def get_identity(request: Request) -> str: """Return the authenticated user's identity (Cloudron username). Reads the validated OIDC session cookie. No header fallback — the app no longer trusts a client-supplied identity header (that path existed only during the proxyAuth → OIDC transition and is now removed). """ if not oidc.is_oidc_configured(): return "" token = request.cookies.get(oidc.SESSION_COOKIE) if not token: return "" return oidc.read_session(token) or "" def is_admin(request: Request) -> bool: identity = get_identity(request) if not identity: return False return identity.lower() in ADMIN_USERNAMES async def portal_get(path: str) -> dict: async with httpx.AsyncClient(timeout=20.0) as client: r = await client.get(f"{PORTAL_BASE}{path}", headers={"X-Admin-Token": ADMIN_TOKEN}) if r.status_code != 200: raise HTTPException(502, f"Portal error {r.status_code}") return r.json() async def portal_post(path: str, payload: dict) -> dict: async with httpx.AsyncClient(timeout=20.0) as client: r = await client.post(f"{PORTAL_BASE}{path}", json=payload, headers={"X-Admin-Token": ADMIN_TOKEN}) if r.status_code not in (200, 201): try: detail = r.json().get("detail", r.text) except Exception: detail = r.text raise HTTPException(502, f"Portal error {r.status_code}: {detail}") return r.json() @app.get("/healthz") async def healthz(): return {"status": "ok"} @app.get("/") async def index(request: Request): identity = get_identity(request) # Unauthenticated + OIDC configured → send to the OIDC login. if not identity: if oidc.is_oidc_configured(): return RedirectResponse("/auth/openid/login", status_code=302) return HTMLResponse( "
This panel is restricted to administrators.
", status_code=403, ) # Authenticated but not an admin → forbid. if identity.lower() not in ADMIN_USERNAMES: return HTMLResponse( "This panel is restricted to administrators.
", status_code=403, ) return HTMLResponse(render_page()) @app.get("/api/overview") async def api_overview(request: Request): if not is_admin(request): return JSONResponse({"error": "forbidden"}, status_code=403) return await portal_get("/admin/overview") @app.get("/api/model-usage") async def api_model_usage(request: Request): if not is_admin(request): return JSONResponse({"error": "forbidden"}, status_code=403) try: return await portal_get("/admin/model-usage") except HTTPException as e: return JSONResponse({"error": e.detail}, status_code=e.status_code) @app.post("/api/set-balance") async def api_set_balance(request: Request): if not is_admin(request): return JSONResponse({"error": "forbidden"}, status_code=403) body = await request.json() email = (body.get("email") or "").strip().lower() if not email: return JSONResponse({"error": "Missing email"}, status_code=400) payload = {"email": email} if "add" in body: payload["add"] = float(body["add"]) elif "balance" in body: payload["balance"] = float(body["balance"]) else: return JSONResponse({"error": "Provide 'balance' or 'add'"}, status_code=400) try: return await portal_post("/admin/set-balance", payload) except HTTPException as e: return JSONResponse({"error": e.detail}, status_code=e.status_code) # --------------------------------------------------------------------------- # OIDC routes — authorization-code flow against Cloudron's OIDC provider. # Active only when the `oidc` addon is configured (CLOUDRON_OIDC_* present). # During the transition the legacy proxyAuth header still works as a fallback. # --------------------------------------------------------------------------- @app.get("/auth/openid/login") async def oidc_login(request: Request): if not oidc.is_oidc_configured(): raise HTTPException(404, "OIDC not configured") login_url, state, nonce = oidc.build_login_url(request.headers.get("host", "")) resp = RedirectResponse(login_url, status_code=302) # Short-lived, HttpOnly, SameSite=Lax cookies to carry state/nonce. resp.set_cookie("oidc_state", state, max_age=600, httponly=True, samesite="lax") resp.set_cookie("oidc_nonce", nonce, max_age=600, httponly=True, samesite="lax") return resp @app.get("/auth/openid/callback") async def oidc_callback(request: Request): if not oidc.is_oidc_configured(): raise HTTPException(404, "OIDC not configured") code = request.query_params.get("code") state = request.query_params.get("state") expected_state = request.cookies.get("oidc_state") nonce = request.cookies.get("oidc_nonce") if not code or not state or not expected_state or not nonce: return HTMLResponse("Incomplete OIDC callback.
", status_code=400) if state != expected_state: return HTMLResponse("State mismatch (possible CSRF).
", status_code=400) try: identity = await oidc.exchange_code(code, request.headers.get("host", ""), nonce) except ValueError as e: logger.warning("OIDC login failed: %s", e) return HTMLResponse("Could not complete sign-in.
", status_code=400) session = oidc.write_session(identity) resp = RedirectResponse("/", status_code=302) resp.set_cookie(oidc.SESSION_COOKIE, session, max_age=oidc.SESSION_MAX_AGE, httponly=True, samesite="lax") resp.delete_cookie("oidc_state") resp.delete_cookie("oidc_nonce") return resp @app.get("/logout") async def logout(): resp = RedirectResponse("/", status_code=302) resp.delete_cookie(oidc.SESSION_COOKIE) return resp def _esc(s: str) -> str: return s.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """) def render_page() -> str: return PAGE_HTML PAGE_HTML = """| Member | Status | Activated | Balance | Spend | Remaining | Reset | Adjust |
|---|---|---|---|---|---|---|---|
| Loading… | |||||||
| Model | Spend | Tokens | Requests |
|---|---|---|---|
| Loading… | |||