From d4e821799c331ebcd9213ffe7de4db2127d30cdd Mon Sep 17 00:00:00 2001 From: kyle Date: Thu, 8 Oct 2026 13:52:29 -0600 Subject: [PATCH] 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 ; 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 --- README.md | 18 ++- backend/app/admin.py | 44 +++++++ backend/app/auth.py | 39 +++++- backend/app/db.py | 21 +++- backend/app/keys.py | 55 +++++++++ backend/app/main.py | 8 +- backend/app/settings.py | 8 ++ backend/manage.py | 41 ++++--- backend/tests/test_admin.py | 146 +++++++++++++++++++++++ backend/tests/test_api.py | 4 +- collectors/mars/README.md | 2 +- collectors/vb365/README.md | 2 +- collectors/veeam/README.md | 2 +- collectors/wsb/README.md | 2 +- deploy/backup-dashboard.env | 2 + public/admin.html | 103 ++++++++++++++++ public/css/styles.css | 79 ++++++++++++- public/index.html | 1 + public/js/admin.js | 228 ++++++++++++++++++++++++++++++++++++ public/js/api.js | 47 +++++++- public/js/app.js | 35 ++---- public/js/dom.js | 12 ++ public/js/mock.js | 39 +++++- public/js/signed-in.js | 18 +++ 24 files changed, 887 insertions(+), 69 deletions(-) create mode 100644 backend/app/admin.py create mode 100644 backend/app/keys.py create mode 100644 backend/tests/test_admin.py create mode 100644 public/admin.html create mode 100644 public/js/admin.js create mode 100644 public/js/dom.js create mode 100644 public/js/signed-in.js diff --git a/README.md b/README.md index 9916f15..8f40471 100644 --- a/README.md +++ b/README.md @@ -79,9 +79,23 @@ The OpenAPI schema is at `/api/v1/openapi.json`. signed-in user can see every tenant. Sessions last 12 hours; signing in again is usually silent. The dashboard shows who's signed in; **Sign out** also signs out of Microsoft in that browser, otherwise the next visit would sign straight back in. +- **Admins:** the sign-in names in `BACKUP_DASH_ADMINS` (in `/etc/backup-dashboard/backup-dashboard.env`, + comma-separated) get an **Admin** link to the API keys page. Restart the API after changing it. - **Collectors:** per-collector bearer keys, checked by the app. Only a SHA-256 hash is stored. - To revoke one, run `python manage.py revoke-key NAME`. `/api/v1/ingest` is the only path that - doesn't need Microsoft sign-in. + `/api/v1/ingest` is the only path that doesn't need Microsoft sign-in. + +### Managing API keys + +On the **Admin** page (`/admin.html`), admins can: +- **Create** a key. It's shown once, with a copy button. Paste it when a collector's installer + asks for the API key. +- **See** each key's status, when it was last used and for which company, and who created it. + Active keys quiet for over 24 hours are flagged, since collectors report hourly. +- **Revoke** a key. The collectors using it are refused from their next run. This can't be undone, + and names stay taken, so the history stays unambiguous. + +The same is possible from the server with `manage.py` (`create-key`, `list-keys`, `revoke-key`), +recorded as created/revoked by `manage.py ()`. ### Microsoft 365 sign-in setup diff --git a/backend/app/admin.py b/backend/app/admin.py new file mode 100644 index 0000000..1044d56 --- /dev/null +++ b/backend/app/admin.py @@ -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} diff --git a/backend/app/auth.py b/backend/app/auth.py index 45a53a1..771ad0f 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -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), diff --git a/backend/app/db.py b/backend/app/db.py index fb315dd..0c038e0 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -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() diff --git a/backend/app/keys.py b/backend/app/keys.py new file mode 100644 index 0000000..31a609b --- /dev/null +++ b/backend/app/keys.py @@ -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) diff --git a/backend/app/main.py b/backend/app/main.py index b9c72da..ec4efbc 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 diff --git a/backend/app/settings.py b/backend/app/settings.py index aa6c20f..6ea74d5 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -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]: + """'kyle@example.com, Pat@Example.com' -> {'kyle@example.com', 'pat@example.com'}""" + return frozenset(part.strip().lower() for part in value.replace(";", ",").split(",") if part.strip()) diff --git a/backend/manage.py b/backend/manage.py index 978ab6f..dd04f2a 100644 --- a/backend/manage.py +++ b/backend/manage.py @@ -15,41 +15,48 @@ Run it as the service user so files it creates stay writable by the API: """ import argparse +import getpass import random import sys from datetime import timedelta from pathlib import Path -from app import db, repo -from app.auth import generate_key, hash_key +from app import db, keys, repo from app.models import IngestIn from app.settings import DEFAULT_ENV_FILE, load_settings -from app.timeutil import to_db, utcnow +from app.timeutil import utcnow + + +def cli_user() -> str: + # Recorded as who created/revoked a key; the admin page records the signed-in user instead + try: + return f"manage.py ({getpass.getuser()})" + except Exception: + return "manage.py" def create_key(conn, args): - key = generate_key() + if not keys.valid_name(args.name): + sys.exit("Key names use letters, digits, '.', '_' and '-', starting with a letter or digit") try: - conn.execute( - "INSERT INTO api_keys (name, key_hash, created_at) VALUES (?, ?, ?)", - (args.name, hash_key(key), to_db(utcnow())), - ) - except db.sqlite3.IntegrityError: + _, secret = keys.create_key(conn, args.name, created_by=cli_user()) + except keys.KeyNameTaken: sys.exit(f"A key named {args.name!r} already exists") - print(f"API key for {args.name!r} (shown once, store it in the collector's config):\n{key}") + print(f"API key for {args.name!r} (shown once, store it in the collector's config):\n{secret}") def list_keys(conn, args): - for r in conn.execute("SELECT name, created_at, last_used_at, revoked_at FROM api_keys ORDER BY name"): - state = f"revoked {r['revoked_at']}" if r["revoked_at"] else "active" - print(f"{r['name']:<30} {state:<30} created {r['created_at']} last used {r['last_used_at'] or 'never'}") + for k in keys.list_keys(conn): + state = f"revoked {k['revoked_at']}" if k["revoked_at"] else "active" + tenant = f" ({k['last_used_tenant']})" if k["last_used_tenant"] else "" + print(f"{k['name']:<30} {state:<30} created {k['created_at']} last used {k['last_used_at'] or 'never'}{tenant}") def revoke_key(conn, args): - cur = conn.execute( - "UPDATE api_keys SET revoked_at = ? WHERE name = ? AND revoked_at IS NULL", (to_db(utcnow()), args.name) - ) - sys.exit(0 if cur.rowcount else f"No active key named {args.name!r}") + row = conn.execute("SELECT id FROM api_keys WHERE name = ? AND revoked_at IS NULL", (args.name,)).fetchone() + if row is None: + sys.exit(f"No active key named {args.name!r}") + keys.revoke_key(conn, row["id"], revoked_by=cli_user()) def list_jobs(conn, args): diff --git a/backend/tests/test_admin.py b/backend/tests/test_admin.py new file mode 100644 index 0000000..6baafa6 --- /dev/null +++ b/backend/tests/test_admin.py @@ -0,0 +1,146 @@ +import sqlite3 + +import pytest +from fastapi.testclient import TestClient + +from app import db +from app.main import create_app +from app.settings import Settings, parse_admins + +ADMIN = {"X-Forwarded-Email": "admin@example.com"} +PAGE = {"X-Requested-By": "backup-dashboard"} +ADMIN_PAGE = ADMIN | PAGE + + +@pytest.fixture +def client(tmp_path): + settings = Settings(db_path=tmp_path / "test.db", admins=frozenset({"admin@example.com"})) + with TestClient(create_app(settings)) as c: + yield c + + +def create(client, name="veeam-acme", headers=ADMIN_PAGE): + return client.post("/api/v1/admin/keys", json={"name": name}, headers=headers) + + +def ingest(client, secret, tenant="Acme"): + return client.post( + "/api/v1/ingest", + json={"tenant": tenant, "source_system": "Veeam B&R", "job": {"external_id": "j1", "name": "Job"}}, + headers={"Authorization": f"Bearer {secret}"}, + ) + + +def test_parse_admins(): + assert parse_admins(" Kyle@Example.com, pat@example.com;sam@example.com ,") == { + "kyle@example.com", "pat@example.com", "sam@example.com"} + assert parse_admins("") == frozenset() + + +def test_me_says_whether_the_user_is_an_admin(client): + assert client.get("/api/v1/me", headers=ADMIN).json()["is_admin"] is True + assert client.get("/api/v1/me", headers={"X-Forwarded-Email": "user@example.com"}).json()["is_admin"] is False + assert client.get("/api/v1/me").json()["is_admin"] is False + + +@pytest.mark.parametrize("headers", [{}, {"X-Forwarded-Email": "user@example.com"}]) +def test_admin_pages_are_admins_only(client, headers): + assert client.get("/api/v1/admin/keys", headers=headers).status_code == 403 + assert create(client, headers=headers | PAGE).status_code == 403 + assert client.post("/api/v1/admin/keys/1/revoke", headers=headers | PAGE).status_code == 403 + + +def test_admin_match_ignores_case_and_accepts_the_sign_in_name(client): + assert client.get("/api/v1/admin/keys", headers={"X-Forwarded-Email": "Admin@Example.COM"}).status_code == 200 + assert client.get("/api/v1/admin/keys", headers={"X-Forwarded-Preferred-Username": "admin@example.com"}).status_code == 200 + + +def test_changes_need_the_page_header(client): + # Without it, a page on another site could make a signed-in admin's browser send the request + assert create(client, headers=ADMIN).status_code == 403 + secret = create(client).json()["secret"] + key_id = client.get("/api/v1/admin/keys", headers=ADMIN).json()["keys"][0]["id"] + assert client.post(f"/api/v1/admin/keys/{key_id}/revoke", headers=ADMIN).status_code == 403 + assert ingest(client, secret).status_code == 200 # still active + + +@pytest.mark.parametrize("name", ["", " ", "-leading-dash", "has space", "a" * 65, "semi;colon"]) +def test_rejects_bad_key_names(client, name): + assert create(client, name=name).status_code == 422 + + +def test_created_key_is_shown_once_and_works_for_ingest(client): + r = create(client, name=" veeam-acme ") + assert r.status_code == 201 + body = r.json() + assert body["secret"].startswith("bdk_") + assert body["key"]["name"] == "veeam-acme" + assert body["key"]["created_by"] == "admin@example.com" + listed = client.get("/api/v1/admin/keys", headers=ADMIN).json()["keys"] + assert "secret" not in listed[0] and "key_hash" not in listed[0] + assert ingest(client, body["secret"]).status_code == 200 + + +def test_key_records_when_and_for_which_company_it_was_last_used(client): + secret = create(client).json()["secret"] + ingest(client, secret, tenant="Acme Corp") + key = client.get("/api/v1/admin/keys", headers=ADMIN).json()["keys"][0] + assert key["last_used_at"] is not None + assert key["last_used_tenant"] == "Acme Corp" + + +def test_duplicate_names_are_refused_even_after_revoking(client): + key_id = create(client).json()["key"]["id"] + assert create(client).status_code == 409 + client.post(f"/api/v1/admin/keys/{key_id}/revoke", headers=ADMIN_PAGE) + assert create(client).status_code == 409 + + +def test_revoking_stops_the_key_and_records_who(client): + created = create(client).json() + r = client.post(f"/api/v1/admin/keys/{created['key']['id']}/revoke", headers=ADMIN_PAGE) + assert r.status_code == 200 + assert r.json()["key"]["revoked_by"] == "admin@example.com" + assert r.json()["key"]["revoked_at"] is not None + assert ingest(client, created["secret"]).status_code == 401 + + +def test_revoking_twice_keeps_the_first_revocation(client): + key_id = create(client).json()["key"]["id"] + first = client.post(f"/api/v1/admin/keys/{key_id}/revoke", headers=ADMIN_PAGE).json()["key"] + again = client.post(f"/api/v1/admin/keys/{key_id}/revoke", + headers={"X-Forwarded-Email": "admin@example.com"} | PAGE).json()["key"] + assert again["revoked_at"] == first["revoked_at"] + + +def test_revoking_an_unknown_key_is_404(client): + assert client.post("/api/v1/admin/keys/999/revoke", headers=ADMIN_PAGE).status_code == 404 + + +def test_lists_active_keys_first(client): + for name in ("b-key", "a-key", "c-key"): + create(client, name=name) + a_id = next(k["id"] for k in client.get("/api/v1/admin/keys", headers=ADMIN).json()["keys"] if k["name"] == "a-key") + client.post(f"/api/v1/admin/keys/{a_id}/revoke", headers=ADMIN_PAGE) + names = [k["name"] for k in client.get("/api/v1/admin/keys", headers=ADMIN).json()["keys"]] + assert names == ["b-key", "c-key", "a-key"] + + +def test_upgrades_a_version_1_database_keeping_its_keys(tmp_path): + path = tmp_path / "old.db" + conn = sqlite3.connect(path) + conn.executescript(db.SCHEMA) # the v1 schema, as deployed before admin pages + conn.execute("INSERT INTO api_keys (name, key_hash, created_at) VALUES ('old-key', 'abc', '2026-10-01T00:00:00Z')") + conn.execute("PRAGMA user_version = 1") + conn.commit() + conn.close() + + db.init_db(path) + db.init_db(path) # and running it again is harmless + + conn = db.connect(path) + assert conn.execute("PRAGMA user_version").fetchone()[0] == db.SCHEMA_VERSION + row = dict(conn.execute("SELECT * FROM api_keys").fetchone()) + assert row["name"] == "old-key" + assert {"created_by", "revoked_by", "last_used_tenant"} <= row.keys() + conn.close() diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 5bbd6ca..5890b9d 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -125,6 +125,6 @@ def test_tenant_name_is_case_insensitive(client): def test_me_reports_signed_in_user_from_proxy_headers(client): - assert client.get("/api/v1/me").json() == {"email": None, "name": None} + assert client.get("/api/v1/me").json() == {"email": None, "name": None, "is_admin": False} r = client.get("/api/v1/me", headers={"X-Forwarded-Email": "kyle@example.com", "X-Forwarded-User": "abc-123"}) - assert r.json() == {"email": "kyle@example.com", "name": "abc-123"} + assert r.json() == {"email": "kyle@example.com", "name": "abc-123", "is_admin": False} diff --git a/collectors/mars/README.md b/collectors/mars/README.md index 6c854eb..fbf6d47 100644 --- a/collectors/mars/README.md +++ b/collectors/mars/README.md @@ -14,7 +14,7 @@ named ` - Azure Backup` by default. Runs under Windows PowerShell 5.1 (`powershell.exe`), which the agent's `MSOnlineBackup` module targets and which every supported Windows Server has. -1. On the dashboard host, create a key for this server: +1. Create a key for this server on the dashboard's **Admin** page, or on the dashboard host: ```bash python manage.py create-key mars-acme-fs01 ``` diff --git a/collectors/vb365/README.md b/collectors/vb365/README.md index 2ab330e..d276f0b 100644 --- a/collectors/vb365/README.md +++ b/collectors/vb365/README.md @@ -23,7 +23,7 @@ one with `python manage.py delete-job `. ## Install (on the VB365 server, as an administrator) -1. On the dashboard host, create a key for this server: +1. Create a key for this server on the dashboard's **Admin** page, or on the dashboard host: ```bash python manage.py create-key vb365-01 ``` diff --git a/collectors/veeam/README.md b/collectors/veeam/README.md index 52162a7..5e122b3 100644 --- a/collectors/veeam/README.md +++ b/collectors/veeam/README.md @@ -9,7 +9,7 @@ Requires PowerShell 7 installed machine-wide (`winget install Microsoft.PowerShe which VBR 13's Veeam module expects. The Microsoft Store version won't work, because the SYSTEM account can't run it. The installer checks for this before changing anything. -1. On the dashboard host, create a key for this server: +1. Create a key for this server on the dashboard's **Admin** page, or on the dashboard host: ```bash python manage.py create-key veeam-acme ``` diff --git a/collectors/wsb/README.md b/collectors/wsb/README.md index e983d7f..159745c 100644 --- a/collectors/wsb/README.md +++ b/collectors/wsb/README.md @@ -15,7 +15,7 @@ Requires the Windows Server Backup feature with its PowerShell module (`Install-WindowsFeature Windows-Server-Backup`). Runs under Windows PowerShell 5.1 (`powershell.exe`), which that module targets. -1. On the dashboard host, create a key for this server: +1. Create a key for this server on the dashboard's **Admin** page, or on the dashboard host: ```bash python manage.py create-key wsb-acme-dc01 ``` diff --git a/deploy/backup-dashboard.env b/deploy/backup-dashboard.env index 5f6f9d9..24c1ba3 100644 --- a/deploy/backup-dashboard.env +++ b/deploy/backup-dashboard.env @@ -2,3 +2,5 @@ # Read by both the API service and manage.py, so they always use the same database. BACKUP_DASH_DB=/var/lib/backup-dashboard/backup-dashboard.db BACKUP_DASH_RETENTION_DAYS=90 +# Microsoft 365 sign-in names (comma-separated) allowed into the admin pages (API key management) +BACKUP_DASH_ADMINS=you@yourcompany.com diff --git a/public/admin.html b/public/admin.html new file mode 100644 index 0000000..da990a3 --- /dev/null +++ b/public/admin.html @@ -0,0 +1,103 @@ + + + + + + API keys · Backup Monitor + + + + +
+
+ ← Dashboard +

API keys

+
+
+ +
+
+ +
+ + + + + + +

+
+ + +
+ + +
+
+ + diff --git a/public/css/styles.css b/public/css/styles.css index 4f033a8..55f0c13 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -344,4 +344,81 @@ button.badge:hover { filter: brightness(0.95); } } .runs tbody tr:last-child td { border-bottom: none; } .runs tr.is-selected td { background: color-mix(in srgb, var(--accent) 16%, transparent); } -.runs .badge { font-size: 0.75rem; padding: 0.1rem 0.5rem; } \ No newline at end of file +.runs .badge { font-size: 0.75rem; padding: 0.1rem 0.5rem; } +/* ---------- Admin page ---------- */ +.container--narrow { max-width: 1100px; } +.topbar__nav { display: flex; align-items: center; gap: 1rem; } + +.banner--info { background: var(--surface-alt); color: var(--text); border-color: var(--border); } + +.panel { + margin-bottom: 1.5rem; + padding: 1rem 1.25rem 1.25rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); +} +.panel__title { font-size: 1.05rem; margin: 0 0 0.25rem; } +section > .panel__title { margin: 0 0 0.75rem; } +.panel__title:focus { outline: none; } /* focused from script after a change, not by the user */ +.panel p { margin: 0 0 0.75rem; } + +code { + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 0.9em; + overflow-wrap: anywhere; +} + +/* New key form: label, hint above the input (so autocomplete can't cover it), error below */ +.new-key { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 0.75rem; } +.field { display: flex; flex-direction: column; gap: 0.25rem; flex: 1 1 22rem; max-width: 30rem; } +.field label { font-weight: 600; } +.hint { font-size: 0.85rem; color: var(--muted); } +.field .input { width: 100%; } +.error-msg { display: none; font-size: 0.85rem; color: var(--failed-fg); } +/* Only after the user has interacted with the field (or tried to submit) */ +.field .input:user-invalid { border-color: var(--failed-bd); background: var(--failed-bg); } +.field .input:user-invalid + .error-msg { display: block; } +.form-error { flex-basis: 100%; margin: 0; color: var(--failed-fg); } + +.btn--primary { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; } +.btn--primary:hover { filter: brightness(1.08); border-color: var(--accent); } +.btn--danger { background: var(--failed-bd); border-color: var(--failed-bd); color: #fff; font-weight: 600; } +.btn--danger-outline { color: var(--failed-fg); border-color: var(--failed-bd); background: transparent; } +.btn--danger-outline:hover { background: var(--failed-bg); border-color: var(--failed-bd); } + +/* The one-time key */ +.secret { + margin-top: 1rem; + padding: 1rem; + border: 1px solid var(--warning-bd); + border-left-width: 4px; + border-radius: 6px; + background: var(--warning-bg); +} +.secret__title { margin: 0 0 0.5rem; font-size: 1rem; } +.secret__title:focus { outline: none; } +.secret__row { display: flex; gap: 0.5rem; align-items: stretch; margin-bottom: 0.75rem; flex-wrap: wrap; } +.secret__value { + flex: 1 1 20rem; + padding: 0.45rem 0.75rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + user-select: all; /* one click selects the whole key */ +} + +/* Key table */ +.keys td { vertical-align: top; } +.keys td .badge { margin-bottom: 0.25rem; } +.keys td > .time__rel { display: block; } +.keys .actions { text-align: right; width: 1%; } +.keys tr.is-revoked td { color: var(--muted); } +.keys tr.is-revoked code { text-decoration: line-through; } +.stale-note { display: block; font-size: 0.8rem; font-weight: 600; color: var(--warning-fg); } + +/* Confirm dialog */ +.modal--small { width: min(460px, calc(100vw - 2rem)); } +.modal--small .modal__body p { margin-top: 0; } +.dialog-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1rem; } diff --git a/public/index.html b/public/index.html index 1788c74..5a76863 100644 --- a/public/index.html +++ b/public/index.html @@ -14,6 +14,7 @@
Loading… +