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())
|
||||
|
||||
+24
-17
@@ -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):
|
||||
|
||||
@@ -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": "[email protected]"}
|
||||
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({"[email protected]"}))
|
||||
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(" [email protected], [email protected];[email protected] ,") == {
|
||||
"[email protected]", "[email protected]", "[email protected]"}
|
||||
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": "[email protected]"}).json()["is_admin"] is False
|
||||
assert client.get("/api/v1/me").json()["is_admin"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("headers", [{}, {"X-Forwarded-Email": "[email protected]"}])
|
||||
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": "[email protected]"}).status_code == 200
|
||||
assert client.get("/api/v1/admin/keys", headers={"X-Forwarded-Preferred-Username": "[email protected]"}).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"] == "[email protected]"
|
||||
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"] == "[email protected]"
|
||||
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": "[email protected]"} | 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()
|
||||
@@ -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": "[email protected]", "X-Forwarded-User": "abc-123"})
|
||||
assert r.json() == {"email": "[email protected]", "name": "abc-123"}
|
||||
assert r.json() == {"email": "[email protected]", "name": "abc-123", "is_admin": False}
|
||||
|
||||
Reference in New Issue
Block a user