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]>
141 lines
6.0 KiB
Python
141 lines
6.0 KiB
Python
#!/usr/bin/env python3
|
|
"""Admin CLI. Finds the database the same way the API does: BACKUP_DASH_DB from the environment,
|
|
else from /etc/backup-dashboard/backup-dashboard.env, else ./backup-dashboard.db (dev).
|
|
Run it as the service user so files it creates stay writable by the API:
|
|
|
|
sudo -u backupdash /opt/backup-dashboard/venv/bin/python manage.py create-key veeam-acme
|
|
|
|
python manage.py create-key veeam-acme # prints the key once
|
|
python manage.py list-keys
|
|
python manage.py revoke-key veeam-acme
|
|
python manage.py list-jobs
|
|
python manage.py delete-job 12 # e.g. a decommissioned job stuck as Overdue
|
|
python manage.py seed-demo # 30 days of fake history for trying the UI
|
|
python manage.py --db ./dev.db --create seed-demo
|
|
"""
|
|
|
|
import argparse
|
|
import getpass
|
|
import random
|
|
import sys
|
|
from datetime import timedelta
|
|
from pathlib import Path
|
|
|
|
from app import db, keys, repo
|
|
from app.models import IngestIn
|
|
from app.settings import DEFAULT_ENV_FILE, load_settings
|
|
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):
|
|
if not keys.valid_name(args.name):
|
|
sys.exit("Key names use letters, digits, '.', '_' and '-', starting with a letter or digit")
|
|
try:
|
|
_, 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{secret}")
|
|
|
|
|
|
def list_keys(conn, args):
|
|
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):
|
|
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):
|
|
for j in repo.list_jobs(conn):
|
|
print(f"{j['id']:>5} {j['company_name']:<28} {j['job_name']:<40} {j['status'] or '-':<8} last {j['last_run_at'] or 'never'}")
|
|
|
|
|
|
def delete_job(conn, args):
|
|
cur = conn.execute("DELETE FROM jobs WHERE id = ?", (args.job_id,))
|
|
sys.exit(0 if cur.rowcount else f"No job with id {args.job_id}")
|
|
|
|
|
|
DEMO = [
|
|
("Acme Corp", "Veeam B&R", "File Server - Nightly", 24),
|
|
("Acme Corp", "Veeam B&R", "SQL01 - Transaction Logs", 4),
|
|
("Bluewater Dental", "Datto", "Workstations - Daily", 24),
|
|
("Bluewater Dental", "Datto", "Imaging Server - Hourly", 1),
|
|
("Northfield Logistics", "Acronis", "Exchange - Nightly", 24),
|
|
]
|
|
|
|
|
|
def seed_demo(conn, args):
|
|
rng = random.Random(42)
|
|
now = utcnow()
|
|
for tenant, source, name, every_h in DEMO:
|
|
ext = name.lower().replace(" ", "-")
|
|
# Offset so runs don't all line up exactly on the current time
|
|
start = now - timedelta(days=30) + timedelta(minutes=rng.randint(5, every_h * 60 - 5))
|
|
while start < now:
|
|
dur = timedelta(minutes=rng.randint(2, 50 if every_h > 1 else 15))
|
|
end = start + dur
|
|
status = "running" if end > now else rng.choices(["success", "warning", "failed"], [90, 7, 3])[0]
|
|
run = {"status": status, "started_at": start.isoformat()}
|
|
if status != "running":
|
|
run |= {"finished_at": end.isoformat(), "size_bytes": rng.randint(10**8, 10**11),
|
|
"message": {"success": "Job completed successfully.",
|
|
"warning": "Completed with warnings: 1 file skipped (locked).",
|
|
"failed": "Error: target repository unreachable."}[status]}
|
|
repo.ingest(conn, IngestIn.model_validate({
|
|
"tenant": tenant, "source_system": source,
|
|
"job": {"external_id": ext, "name": name,
|
|
"next_run_at": (start + timedelta(hours=every_h)).isoformat()},
|
|
"run": run,
|
|
}))
|
|
start += timedelta(hours=every_h)
|
|
print(f"Seeded {len(DEMO)} demo jobs")
|
|
|
|
|
|
def main():
|
|
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
p.add_argument("--db", type=Path, help="database file (overrides BACKUP_DASH_DB and the env file)")
|
|
p.add_argument("--create", action="store_true", help="create the database if it doesn't exist")
|
|
sub = p.add_subparsers(required=True)
|
|
s = sub.add_parser("create-key"); s.add_argument("name"); s.set_defaults(fn=create_key)
|
|
s = sub.add_parser("list-keys"); s.set_defaults(fn=list_keys)
|
|
s = sub.add_parser("revoke-key"); s.add_argument("name"); s.set_defaults(fn=revoke_key)
|
|
s = sub.add_parser("list-jobs"); s.set_defaults(fn=list_jobs)
|
|
s = sub.add_parser("delete-job"); s.add_argument("job_id", type=int); s.set_defaults(fn=delete_job)
|
|
s = sub.add_parser("seed-demo"); s.set_defaults(fn=seed_demo)
|
|
args = p.parse_args()
|
|
|
|
db_path = args.db or load_settings().db_path
|
|
# Refuse to silently create a fresh DB: that almost always means we're looking in the wrong
|
|
# place, and anything written there (like a new API key) would be invisible to the service.
|
|
if not db_path.exists() and not args.create:
|
|
sys.exit(
|
|
f"Database not found: {db_path.resolve()}\n"
|
|
f"Set BACKUP_DASH_DB in {DEFAULT_ENV_FILE} (or pass --db), "
|
|
"or add --create to start a new database there."
|
|
)
|
|
print(f"Using database {db_path.resolve()}", file=sys.stderr)
|
|
db.init_db(db_path)
|
|
conn = db.connect(db_path)
|
|
try:
|
|
args.fn(conn, args)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|