183904faad
Auth seeding (last-resort guard) - `_seed_builtin_admin()` in get_db() now skips when VACUUM_WALL_SEED_BUILTIN_ADMIN=0 or when the users table already contains any user — previously a fresh service start after a non-default bootstrap (e.g. --mgmt-user alice) seeded a hard-coded `admin` with an unrecoverable random password, shadowing the operator's account - bootstrap_auth.py sets VACUUM_WALL_SEED_BUILTIN_ADMIN=0: bootstrap creates the operator user itself on a fresh install, so exactly one account exists and no seeded admin can appear Frontend (session recovery) - on page load/restore the in-memory TTL timer is gone, so a valid 7-day refresh token could sit in sessionStorage while the access token is already expired server-side: the session `check` now attempts exactly one refresh (POST /api/auth/refresh with the stored refresh token) on 401 before treating the session as dead - extract shared `_doRefresh()` used by both the `check` 401 fallback and the `refresh` action (removes the duplicated rotation logic) Tests - update seeding tests to the new any-user-present check; add test_seed_skipped_when_users_exist, test_seed_skipped_via_env, test_bootstrap_flow_creates_exactly_one_user, and the auth-model JS test suite (tests/test-auth-model.js) Docs - AGENTS.md: document VACUUM_WALL_SEED_BUILTIN_ADMIN - architecture.md / hoover.md / security.md: describe the bootstrap check 401 → one-refresh fallback path
96 lines
3.6 KiB
Python
96 lines
3.6 KiB
Python
"""Bootstrap auth: initialize DB and seed admin user at install time.
|
|
|
|
Idempotent — safe to run on every install (and re-install):
|
|
|
|
- Writes config/auth/config.json only if it does not exist (existing
|
|
JWT/WebAuthn settings are preserved).
|
|
- Creates the admin user if missing; if the user already exists, updates
|
|
the admin password to the provided value (docs/deployment.md: "On
|
|
re-run, updates the admin password if already present").
|
|
- Suppresses the last-resort builtin admin seed (VACUUM_WALL_SEED_BUILTIN_ADMIN=0):
|
|
bootstrap is the operator user's creator on a fresh install, so exactly
|
|
one account exists and no hardcoded admin with an unrecoverable random
|
|
password is left behind.
|
|
|
|
Usage:
|
|
python scripts/bootstrap_auth.py --project-dir /path/to/project \
|
|
--username admin \
|
|
--password secret \
|
|
--domain wall.example.com
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# Ensure project lib is importable
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="Bootstrap Vacuum Wall auth")
|
|
parser.add_argument("--project-dir", required=True, help="Project root directory")
|
|
parser.add_argument("--username", required=True, help="Admin username")
|
|
parser.add_argument("--password", required=True, help="Admin password")
|
|
parser.add_argument("--domain", required=True, help="Management domain (rp_id)")
|
|
args = parser.parse_args()
|
|
|
|
project_dir = Path(args.project_dir).resolve()
|
|
sys.path.insert(0, str(project_dir))
|
|
|
|
# Set DB path before importing lib modules
|
|
db_path = str(project_dir / "data" / "auth.db")
|
|
os.environ["VACUUM_WALL_DB_BACKEND"] = "sqlite"
|
|
os.environ["VACUUM_WALL_DB_PATH"] = db_path
|
|
# Suppress the last-resort builtin admin seed in get_db(): bootstrap
|
|
# creates the operator user itself, so no seeded admin may shadow it.
|
|
os.environ["VACUUM_WALL_SEED_BUILTIN_ADMIN"] = "0"
|
|
|
|
from lib.auth_users import (
|
|
ALL_SUBSYSTEMS,
|
|
create_user,
|
|
find_user,
|
|
reset_password,
|
|
)
|
|
|
|
# Write config — only if missing, so re-runs never clobber existing
|
|
# JWT/WebAuthn settings (e.g. a customized rp_id/origin).
|
|
config_dir = project_dir / "config" / "auth"
|
|
config_dir.mkdir(parents=True, exist_ok=True)
|
|
config_path = config_dir / "config.json"
|
|
|
|
if not config_path.exists():
|
|
config = {
|
|
"jwt": {
|
|
"access_token_ttl": 300,
|
|
"refresh_token_ttl": 604800,
|
|
"algorithm": "HS256",
|
|
},
|
|
"webauthn": {
|
|
"rp_name": "Vacuum Wall",
|
|
"rp_id": args.domain,
|
|
"origin": f"https://{args.domain}",
|
|
},
|
|
}
|
|
with open(config_path, "w") as f:
|
|
json.dump(config, f, indent=2)
|
|
f.write("\n")
|
|
print(f"Wrote auth config: {config_path}")
|
|
else:
|
|
print(f"Auth config already present, leaving unchanged: {config_path}")
|
|
|
|
# Initialize DB and create the admin user, or sync the password on re-run
|
|
if find_user(args.username) is not None:
|
|
reset_password(args.username, args.password)
|
|
print(f"Updated existing user: {args.username} (password synced)")
|
|
else:
|
|
permissions = {sub: "rw" for sub in ALL_SUBSYSTEMS}
|
|
user = create_user(args.username, args.password, permissions)
|
|
print(f"Created admin user: {user['username']} (id={user['id']})")
|
|
print(f"Permissions: {len(permissions)} subsystems, all 'rw'")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|