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]>
133 lines
4.2 KiB
Python
133 lines
4.2 KiB
Python
import asyncio
|
|
import logging
|
|
import sqlite3
|
|
from contextlib import asynccontextmanager
|
|
from datetime import timedelta
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Query, Request
|
|
|
|
from . import admin, db, repo
|
|
from .auth import is_admin, require_ingest_key
|
|
from .models import IngestIn
|
|
from .settings import HISTORY_DAYS, Settings, load_settings
|
|
from .timeutil import to_db, utcnow
|
|
|
|
log = logging.getLogger("backup_dashboard")
|
|
PURGE_INTERVAL_SECONDS = 6 * 3600
|
|
|
|
router = APIRouter(prefix="/api/v1")
|
|
|
|
|
|
@router.get("/health")
|
|
def health(conn: sqlite3.Connection = Depends(db.get_db)):
|
|
conn.execute("SELECT 1")
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.get("/me")
|
|
def me(request: Request):
|
|
# Set by nginx from oauth2-proxy's sign-in check (nginx overwrites anything the browser sends).
|
|
# Display only: access control happens in nginx. Empty in dev or without Microsoft sign-in.
|
|
return {
|
|
"email": request.headers.get("x-forwarded-email") or None,
|
|
"name": request.headers.get("x-forwarded-preferred-username") or request.headers.get("x-forwarded-user") or None,
|
|
"is_admin": is_admin(request),
|
|
}
|
|
|
|
|
|
@router.get("/jobs")
|
|
def list_jobs(conn: sqlite3.Connection = Depends(db.get_db)):
|
|
return {"generated_at": to_db(utcnow()), "jobs": repo.list_jobs(conn)}
|
|
|
|
|
|
@router.get("/jobs/{job_id}")
|
|
def get_job(job_id: int, conn: sqlite3.Connection = Depends(db.get_db)):
|
|
job = repo.get_job(conn, job_id)
|
|
if job is None:
|
|
raise HTTPException(404, "Job not found")
|
|
return job
|
|
|
|
|
|
@router.get("/jobs/{job_id}/runs")
|
|
def list_runs(
|
|
job_id: int,
|
|
days: int = Query(HISTORY_DAYS, ge=1, le=HISTORY_DAYS),
|
|
conn: sqlite3.Connection = Depends(db.get_db),
|
|
):
|
|
if conn.execute("SELECT 1 FROM jobs WHERE id = ?", (job_id,)).fetchone() is None:
|
|
raise HTTPException(404, "Job not found")
|
|
since = utcnow() - timedelta(days=days)
|
|
return {"job_id": job_id, "since": to_db(since), "runs": repo.list_runs(conn, job_id, since)}
|
|
|
|
|
|
@router.get("/runs/{run_id}")
|
|
def get_run(run_id: int, conn: sqlite3.Connection = Depends(db.get_db)):
|
|
run = repo.get_run(conn, run_id)
|
|
if run is None:
|
|
raise HTTPException(404, "Run not found")
|
|
return run
|
|
|
|
|
|
@router.post("/ingest")
|
|
def ingest(
|
|
item: IngestIn,
|
|
key_name: str = Depends(require_ingest_key),
|
|
conn: sqlite3.Connection = Depends(db.get_db),
|
|
):
|
|
job_id, run_id = repo.ingest(conn, item)
|
|
# Shown on the admin page, so you can tell what each key is used for
|
|
conn.execute("UPDATE api_keys SET last_used_tenant = ? WHERE name = ?", (item.tenant, key_name))
|
|
log.info("ingest key=%s tenant=%r job=%r run=%s", key_name, item.tenant, item.job.name, run_id)
|
|
return {"job_id": job_id, "run_id": run_id}
|
|
|
|
|
|
async def _purge_loop(settings: Settings) -> None:
|
|
def purge() -> int:
|
|
conn = db.connect(settings.db_path)
|
|
try:
|
|
return repo.purge_old_runs(conn, settings.retention_days)
|
|
finally:
|
|
conn.close()
|
|
|
|
while True:
|
|
try:
|
|
if deleted := await asyncio.to_thread(purge):
|
|
log.info("purged %d runs older than %d days", deleted, settings.retention_days)
|
|
except Exception:
|
|
log.exception("run purge failed")
|
|
await asyncio.sleep(PURGE_INTERVAL_SECONDS)
|
|
|
|
|
|
def create_app(settings: Settings | None = None) -> FastAPI:
|
|
settings = settings or load_settings()
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
db.init_db(settings.db_path)
|
|
task = asyncio.create_task(_purge_loop(settings))
|
|
try:
|
|
yield
|
|
finally:
|
|
task.cancel()
|
|
|
|
# Interactive docs are off: nginx's strict CSP would block their CDN assets anyway.
|
|
app = FastAPI(
|
|
title="Backup Dashboard API",
|
|
lifespan=lifespan,
|
|
docs_url=None,
|
|
redoc_url=None,
|
|
openapi_url="/api/v1/openapi.json",
|
|
)
|
|
app.state.settings = settings
|
|
app.include_router(router)
|
|
app.include_router(admin.router)
|
|
|
|
if settings.serve_static:
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
public = Path(__file__).resolve().parents[2] / "public"
|
|
app.mount("/", StaticFiles(directory=public, html=True), name="static")
|
|
|
|
return app
|