Files
Backup-Dashboard/backend/app/admin.py
T
kyleandClaude Opus 5.5 d4e821799c Add an admin page for managing collector API keys
Admins (Microsoft 365 sign-in names in BACKUP_DASH_ADMINS) get an
Admin link to /admin.html, where they can create, list and revoke the
API keys collectors use, instead of running manage.py on the server.

Backend
- /api/v1/admin/keys (list, create, revoke), admins only (403
  otherwise); the user comes from the headers nginx sets after sign-in
- Changes need an X-Requested-By header, which another site's page
  can't add, so a signed-in admin's browser can't be used to forge them
- A new key is returned once; only its hash is stored. Names are unique
  for good, so revoked keys' history stays unambiguous
- Keys record who created/revoked them and the company they last
  reported for (schema v2; databases migrate automatically on start)
- /api/v1/me reports is_admin; manage.py shares the key code and
  records itself as creator/revoker

Frontend
- admin.html / admin.js: name validation shown after interaction
  (:user-invalid, aria-invalid kept in sync), the one-time key with a
  copy button (falls back to selecting it if clipboard access is
  refused), a key table flagging keys quiet for over 24 hours, and
  revoking confirmed in a <dialog>; results announced in a status region
- Shared el() and signed-in helpers moved to dom.js / signed-in.js
- Mock mode (?mock=1) includes sample keys

Tested: 15 new backend tests (access, forged requests, names,
duplicates, revoking, last-used company, v1 -> v2 upgrade); in the
browser, every admin flow in mock mode and at phone width; end to end
through the nginx config with the real API (a key created on the API
works for ingest; revoking it makes ingest 401; non-admins see an
explanation and no Admin link).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 13:52:29 -06:00

45 lines
1.7 KiB
Python

"""Admin API: managing collector API keys. Only for users in BACKUP_DASH_ADMINS."""
import logging
import sqlite3
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, StringConstraints
from . import db, keys
from .auth import require_admin, require_page_request
log = logging.getLogger("backup_dashboard")
router = APIRouter(prefix="/api/v1/admin", dependencies=[Depends(require_admin)])
class NewKey(BaseModel):
name: Annotated[str, StringConstraints(strip_whitespace=True, pattern=f"^{keys.NAME_PATTERN}$")]
@router.get("/keys")
def list_keys(conn: sqlite3.Connection = Depends(db.get_db)):
return {"keys": keys.list_keys(conn)}
@router.post("/keys", status_code=201, dependencies=[Depends(require_page_request)])
def create_key(body: NewKey, admin: str = Depends(require_admin), conn: sqlite3.Connection = Depends(db.get_db)):
try:
record, secret = keys.create_key(conn, body.name, created_by=admin)
except keys.KeyNameTaken:
raise HTTPException(409, f"A key named {body.name!r} already exists (revoked keys keep their names)") from None
log.info("admin %s created API key %r", admin, body.name)
# The key itself is returned this once; only its hash is stored
return {"key": record, "secret": secret}
@router.post("/keys/{key_id}/revoke", dependencies=[Depends(require_page_request)])
def revoke_key(key_id: int, admin: str = Depends(require_admin), conn: sqlite3.Connection = Depends(db.get_db)):
record = keys.revoke_key(conn, key_id, revoked_by=admin)
if record is None:
raise HTTPException(404, "Key not found")
log.info("admin %s revoked API key %r", admin, record["name"])
return {"key": record}