Phase 1 — multi-user auth: - server/auth.py: bcrypt passwords, itsdangerous signed-cookie sessions, sha256 bearer tokens, FastAPI current_user/require_owner deps, login rate limit - users + api_tokens tables + jobs/assets.user_id (additive migrations) - HARD RULE enforced server-side: guests are local-only — /api/operators filters out requires_env operators, POST /api/jobs 403s cloud ops for non-owners (proven via direct POST in smoke.sh, not just UI). Settings owner-only. auth_secret hidden from the settings API. Per-user active-job cap (owner exempt). Own-asset/ own-job checks. WS auth via cookie or ?token=. Owner bootstrap prints pw once. - mb-ready: bearer MB_TOKEN; scripts/users.py for out-of-band management - Frontend: Login gate, header user chip + logout, guest note, username on jobs, Users panel in Settings (owner) Phase 2 — dashboard: - server/sysinfo.py: psutil CPU/RAM/disk + macmon Apple GPU (util/power/temp, no sudo), computed lane occupancy, 24h job summary, recent jobs w/ output thumbs; all cached (5s stats, 5min du). /api/system + /api/jobs/recent. - Dashboard.jsx: snapshot-on-refresh (no polling) — stat cards, per-core strip, lane strip, running/queued, recent grid. tests/smoke.sh rewritten for auth: 28 checks passing incl. all guest-security rules. Browser-verified owner + guest + dashboard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
104 lines
2.9 KiB
Python
104 lines
2.9 KiB
Python
import json
|
|
import os
|
|
import sqlite3
|
|
import time
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
# Tests / alternate roots can override the data dir.
|
|
DATA = Path(os.environ.get("MODELBEAST_DATA", str(ROOT / "data")))
|
|
DB_PATH = DATA / "modelbeast.db"
|
|
|
|
SCHEMA = """
|
|
CREATE TABLE IF NOT EXISTS assets (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
kind TEXT NOT NULL,
|
|
path TEXT NOT NULL,
|
|
size INTEGER NOT NULL DEFAULT 0,
|
|
meta TEXT NOT NULL DEFAULT '{}',
|
|
parent_job TEXT,
|
|
created_at REAL NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS jobs (
|
|
id TEXT PRIMARY KEY,
|
|
operator TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'queued',
|
|
asset_id TEXT,
|
|
asset_ids TEXT NOT NULL DEFAULT '[]',
|
|
params TEXT NOT NULL DEFAULT '{}',
|
|
outdir TEXT,
|
|
log TEXT NOT NULL DEFAULT '',
|
|
error TEXT,
|
|
created_at REAL NOT NULL,
|
|
started_at REAL,
|
|
finished_at REAL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS settings (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL DEFAULT ''
|
|
);
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id TEXT PRIMARY KEY,
|
|
username TEXT UNIQUE NOT NULL,
|
|
pw_hash TEXT NOT NULL,
|
|
role TEXT NOT NULL DEFAULT 'guest', -- 'owner' | 'guest'; guests are LOCAL-ONLY
|
|
max_active_jobs INTEGER NOT NULL DEFAULT 4,
|
|
created_at REAL NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS api_tokens (
|
|
token_hash TEXT PRIMARY KEY, -- sha256 hex of the raw token
|
|
user_id TEXT NOT NULL,
|
|
name TEXT NOT NULL DEFAULT '',
|
|
created_at REAL NOT NULL
|
|
);
|
|
"""
|
|
|
|
# Columns added after the original Phase 0 schema; applied idempotently so an
|
|
# existing data/modelbeast.db upgrades in place.
|
|
MIGRATIONS = [
|
|
("jobs", "asset_ids", "TEXT NOT NULL DEFAULT '[]'"),
|
|
("jobs", "user_id", "TEXT"), # nullable; legacy rows = owner-era
|
|
("assets", "user_id", "TEXT"),
|
|
]
|
|
|
|
|
|
def connect() -> sqlite3.Connection:
|
|
DATA.mkdir(parents=True, exist_ok=True)
|
|
# FastAPI sync endpoints run in a threadpool; python sqlite3 is built in
|
|
# serialized threading mode, so sharing one connection across threads is safe.
|
|
con = sqlite3.connect(DB_PATH, check_same_thread=False)
|
|
con.row_factory = sqlite3.Row
|
|
con.execute("PRAGMA journal_mode=WAL")
|
|
con.executescript(SCHEMA)
|
|
_migrate(con)
|
|
return con
|
|
|
|
|
|
def _migrate(con: sqlite3.Connection) -> None:
|
|
for table, column, decl in MIGRATIONS:
|
|
cols = {r["name"] for r in con.execute(f"PRAGMA table_info({table})")}
|
|
if column not in cols:
|
|
con.execute(f"ALTER TABLE {table} ADD COLUMN {column} {decl}")
|
|
con.commit()
|
|
|
|
|
|
def new_id() -> str:
|
|
return uuid.uuid4().hex[:12]
|
|
|
|
|
|
def now() -> float:
|
|
return time.time()
|
|
|
|
|
|
def row_to_dict(row: sqlite3.Row) -> dict:
|
|
d = dict(row)
|
|
for key in ("meta", "params", "asset_ids"):
|
|
if key in d and isinstance(d[key], str):
|
|
try:
|
|
d[key] = json.loads(d[key])
|
|
except ValueError:
|
|
pass
|
|
return d
|