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]>
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
"""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}
|
||||
+35
-4
@@ -1,14 +1,16 @@
|
||||
"""Collector (machine) authentication.
|
||||
"""Authentication and authorisation.
|
||||
|
||||
Dashboard *users* are authenticated by nginx in front of the app (Microsoft 365 sign-in via
|
||||
oauth2-proxy), so nothing here deals with people.
|
||||
Collectors (machines) authenticate with API keys, checked here. Dashboard *users* sign in with
|
||||
Microsoft 365 in nginx (via oauth2-proxy), which then tells the app who they are in
|
||||
X-Forwarded-* headers it sets itself (a browser can't supply them). The app only decides whether
|
||||
that user is an admin.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
import sqlite3
|
||||
|
||||
from fastapi import Depends, Header, HTTPException
|
||||
from fastapi import Depends, Header, HTTPException, Request
|
||||
|
||||
from .db import get_db
|
||||
from .timeutil import to_db, utcnow
|
||||
@@ -25,6 +27,35 @@ def hash_key(key: str) -> str:
|
||||
return hashlib.sha256(key.encode()).hexdigest()
|
||||
|
||||
|
||||
def current_user(request: Request) -> str | None:
|
||||
"""The signed-in user's sign-in name, lowercase; None without Microsoft sign-in (e.g. dev)."""
|
||||
user = request.headers.get("x-forwarded-email") or request.headers.get("x-forwarded-preferred-username")
|
||||
return user.strip().lower() if user and user.strip() else None
|
||||
|
||||
|
||||
def is_admin(request: Request) -> bool:
|
||||
user = current_user(request)
|
||||
return user is not None and user in request.app.state.settings.admins
|
||||
|
||||
|
||||
def require_admin(request: Request) -> str:
|
||||
if not is_admin(request):
|
||||
raise HTTPException(403, "Admins only. Admins are listed in BACKUP_DASH_ADMINS on the server.")
|
||||
return current_user(request)
|
||||
|
||||
|
||||
# Changes made from the admin page must carry this header. A page on another site can't add a
|
||||
# custom header to a request to the dashboard (the browser would first ask the API, which allows
|
||||
# no other origins), so a signed-in admin's browser can't be used to forge them.
|
||||
PAGE_HEADER = "X-Requested-By"
|
||||
PAGE_HEADER_VALUE = "backup-dashboard"
|
||||
|
||||
|
||||
def require_page_request(x_requested_by: str | None = Header(None)) -> None:
|
||||
if x_requested_by != PAGE_HEADER_VALUE:
|
||||
raise HTTPException(403, f"Missing {PAGE_HEADER} header")
|
||||
|
||||
|
||||
def require_ingest_key(
|
||||
authorization: str | None = Header(None),
|
||||
conn: sqlite3.Connection = Depends(get_db),
|
||||
|
||||
+18
-3
@@ -5,7 +5,7 @@ from pathlib import Path
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
SCHEMA_VERSION = 2
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS tenants (
|
||||
@@ -67,6 +67,17 @@ def connect(path: Path | str) -> sqlite3.Connection:
|
||||
return conn
|
||||
|
||||
|
||||
# Changes since SCHEMA (which is v1), applied in order to bring any database up to SCHEMA_VERSION
|
||||
MIGRATIONS = {
|
||||
2: [
|
||||
# Who created/revoked a key from the admin page, and which company it last reported for
|
||||
"ALTER TABLE api_keys ADD COLUMN created_by TEXT",
|
||||
"ALTER TABLE api_keys ADD COLUMN revoked_by TEXT",
|
||||
"ALTER TABLE api_keys ADD COLUMN last_used_tenant TEXT",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def init_db(path: Path | str) -> None:
|
||||
conn = connect(path)
|
||||
try:
|
||||
@@ -74,8 +85,12 @@ def init_db(path: Path | str) -> None:
|
||||
version = conn.execute("PRAGMA user_version").fetchone()[0]
|
||||
if version > SCHEMA_VERSION:
|
||||
raise RuntimeError(f"Database schema v{version} is newer than this app (v{SCHEMA_VERSION})")
|
||||
conn.executescript(SCHEMA)
|
||||
conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
||||
conn.executescript(SCHEMA) # v1 tables, for a new database
|
||||
with transaction(conn):
|
||||
for target in range(max(version, 1) + 1, SCHEMA_VERSION + 1):
|
||||
for statement in MIGRATIONS[target]:
|
||||
conn.execute(statement)
|
||||
conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Collector API keys: create, list, revoke. Used by the admin API and manage.py."""
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
|
||||
from .auth import generate_key, hash_key
|
||||
from .timeutil import to_db, utcnow
|
||||
|
||||
# Letters, digits, '.', '_', '-'; starts with a letter or digit. E.g. "veeam-acme", "mars.fs01"
|
||||
NAME_PATTERN = r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}"
|
||||
|
||||
_FIELDS = "id, name, created_at, created_by, last_used_at, last_used_tenant, revoked_at, revoked_by"
|
||||
|
||||
|
||||
class KeyNameTaken(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def valid_name(name: str) -> bool:
|
||||
return re.fullmatch(NAME_PATTERN, name) is not None
|
||||
|
||||
|
||||
def list_keys(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||
# Active keys first, then by name
|
||||
rows = conn.execute(f"SELECT {_FIELDS} FROM api_keys ORDER BY revoked_at IS NOT NULL, name").fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_key(conn: sqlite3.Connection, key_id: int) -> dict[str, Any] | None:
|
||||
row = conn.execute(f"SELECT {_FIELDS} FROM api_keys WHERE id = ?", (key_id,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def create_key(conn: sqlite3.Connection, name: str, created_by: str) -> tuple[dict[str, Any], str]:
|
||||
"""Returns the stored key's record and the key itself, which isn't stored and can't be shown again."""
|
||||
secret = generate_key()
|
||||
try:
|
||||
key_id = conn.execute(
|
||||
"INSERT INTO api_keys (name, key_hash, created_at, created_by) VALUES (?, ?, ?, ?) RETURNING id",
|
||||
(name, hash_key(secret), to_db(utcnow()), created_by),
|
||||
).fetchone()[0]
|
||||
except sqlite3.IntegrityError:
|
||||
# Names stay taken after revocation, so the audit trail stays unambiguous
|
||||
raise KeyNameTaken(name) from None
|
||||
return get_key(conn, key_id), secret
|
||||
|
||||
|
||||
def revoke_key(conn: sqlite3.Connection, key_id: int, revoked_by: str) -> dict[str, Any] | None:
|
||||
"""Revokes the key (no-op if already revoked). Returns its record, or None if there's no such key."""
|
||||
conn.execute(
|
||||
"UPDATE api_keys SET revoked_at = ?, revoked_by = ? WHERE id = ? AND revoked_at IS NULL",
|
||||
(to_db(utcnow()), revoked_by, key_id),
|
||||
)
|
||||
return get_key(conn, key_id)
|
||||
+6
-2
@@ -7,8 +7,8 @@ from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Query, Request
|
||||
|
||||
from . import db, repo
|
||||
from .auth import require_ingest_key
|
||||
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
|
||||
@@ -32,6 +32,7 @@ def me(request: Request):
|
||||
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),
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +76,8 @@ def ingest(
|
||||
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}
|
||||
|
||||
@@ -118,6 +121,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
)
|
||||
app.state.settings = settings
|
||||
app.include_router(router)
|
||||
app.include_router(admin.router)
|
||||
|
||||
if settings.serve_static:
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
@@ -15,6 +15,8 @@ class Settings:
|
||||
retention_days: int = 90
|
||||
# Dev only: serve ../public from the API so you can run without nginx.
|
||||
serve_static: bool = False
|
||||
# Microsoft 365 sign-in names allowed into the admin pages (lowercase)
|
||||
admins: frozenset[str] = frozenset()
|
||||
|
||||
|
||||
def read_env_file(path: Path) -> dict[str, str]:
|
||||
@@ -40,4 +42,10 @@ def load_settings() -> Settings:
|
||||
db_path=Path(env.get("BACKUP_DASH_DB", "backup-dashboard.db")),
|
||||
retention_days=max(HISTORY_DAYS, int(env.get("BACKUP_DASH_RETENTION_DAYS", "90"))),
|
||||
serve_static=env.get("BACKUP_DASH_SERVE_STATIC") == "1",
|
||||
admins=parse_admins(env.get("BACKUP_DASH_ADMINS", "")),
|
||||
)
|
||||
|
||||
|
||||
def parse_admins(value: str) -> frozenset[str]:
|
||||
"""'[email protected], [email protected]' -> {'[email protected]', '[email protected]'}"""
|
||||
return frozenset(part.strip().lower() for part in value.replace(";", ",").split(",") if part.strip())
|
||||
|
||||
Reference in New Issue
Block a user