nginx now checks every dashboard and API request with oauth2-proxy (auth_request), which signs users in with Microsoft Entra ID. Who may sign in is controlled in Entra: a single-tenant app registration with "Assignment required", so only assigned users/groups get through. - deploy/oauth2-proxy.cfg (entra-id provider, auth-only mode, secure cookie, trusted proxy = localhost) and deploy/oauth2-proxy.service - nginx: pages redirect to Microsoft sign-in and back to the same URL; API calls get a plain 401 (the page already asks to reload); /api/v1/ingest stays key-only for collectors; /oauth2/auth is internal; the signed-in user is passed to the API in headers nginx sets (so browsers can't spoof them); dashboard files are served with Cache-Control: no-cache so updates show up at once - GET /api/v1/me; the top bar shows the signed-in user and a Sign out that also signs out of Microsoft (otherwise SSO signs straight back in) - README: Entra and server setup steps Tested locally with oauth2-proxy 7.15.5 and nginx 1.31 running the deploy configs (TLS, self-signed): redirects to the tenant's sign-in with the return URL kept, 401s, ingest bypass, sign-out allow-list, cookie flags, security headers. The signed-in path was tested with a stand-in for oauth2-proxy's auth answer; a real Entra sign-in wasn't. Co-Authored-By: Claude Opus 5.5 <[email protected]>
129 lines
3.9 KiB
Python
129 lines
3.9 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 db, repo
|
|
from .auth import 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,
|
|
}
|
|
|
|
|
|
@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)
|
|
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)
|
|
|
|
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
|