diff --git a/backend/src/cyclone/__main__.py b/backend/src/cyclone/__main__.py index 153b5f3..4a3f888 100644 --- a/backend/src/cyclone/__main__.py +++ b/backend/src/cyclone/__main__.py @@ -16,6 +16,14 @@ import sys def main() -> None: + # Always run first-admin bootstrap before any other entry path. + # Must happen before ``serve`` (uvicorn) AND before the Click CLI + # dispatch — otherwise `python -m cyclone users create ...` on a + # fresh DB would race with the bootstrap's check, and the API + # could come up with zero users. + from cyclone.auth import bootstrap + bootstrap.run() + if len(sys.argv) >= 2 and sys.argv[1] == "serve": port = os.environ.get("CYCLONE_PORT", "8000") reload = os.environ.get("CYCLONE_RELOAD", "0") == "1" diff --git a/backend/src/cyclone/auth/bootstrap.py b/backend/src/cyclone/auth/bootstrap.py new file mode 100644 index 0000000..ca8f72a --- /dev/null +++ b/backend/src/cyclone/auth/bootstrap.py @@ -0,0 +1,70 @@ +"""First-admin bootstrap: create the initial admin from env vars if no users exist. + +Called from ``python -m cyclone`` before either ``cli.main()`` or +``uvicorn`` so users exist by the time the API serves requests. + +Precedence: + +1. ``CYCLONE_AUTH_DISABLED=1`` — dev escape hatch. Flip the + ``cyclone.auth.deps.AUTH_DISABLED`` flag so the API returns a + synthetic admin user without checking credentials. Never raises. +2. Users table non-empty — no-op. +3. ``CYCLONE_ADMIN_USERNAME`` + ``CYCLONE_ADMIN_PASSWORD`` env vars set + (password >= 12 chars) — create the admin and print confirmation. +4. Otherwise — raise ``RuntimeError`` with a remediation hint that + points operators at ``python -m cyclone users create``. +""" + +from __future__ import annotations + +import os + +from sqlalchemy import select + +from cyclone.auth import users +from cyclone.auth.deps import AUTH_DISABLED +from cyclone.auth.permissions import Role +from cyclone.db import SessionLocal, User + + +def run() -> None: + """Bootstrap the first admin user, or no-op. + + See module docstring for behavior. Idempotent: safe to call on + every startup — it short-circuits as soon as the users table is + non-empty. + """ + if os.environ.get("CYCLONE_AUTH_DISABLED") == "1": + # Dev escape hatch — skip bootstrap entirely and tell the API + # to also short-circuit auth checks. + import cyclone.auth.deps as _deps + + _deps.AUTH_DISABLED = True + return + + username = os.environ.get("CYCLONE_ADMIN_USERNAME") + password = os.environ.get("CYCLONE_ADMIN_PASSWORD") + + with SessionLocal()() as db: + existing = db.execute(select(User)).scalars().first() + if existing is not None: + return # users exist — nothing to bootstrap + + if not username or not password: + raise RuntimeError( + "Cyclone has no users yet. Set CYCLONE_ADMIN_USERNAME and " + "CYCLONE_ADMIN_PASSWORD env vars (min 12 chars), or run " + "`python -m cyclone users create --role admin`." + ) + if len(password) < 12: + raise RuntimeError( + "CYCLONE_ADMIN_PASSWORD must be at least 12 characters." + ) + + users.create( + db, + username=username, + password=password, + role=Role.ADMIN.value, + ) + print(f"[cyclone] bootstrap admin user '{username}' created") diff --git a/backend/tests/test_auth_bootstrap.py b/backend/tests/test_auth_bootstrap.py new file mode 100644 index 0000000..53829c7 --- /dev/null +++ b/backend/tests/test_auth_bootstrap.py @@ -0,0 +1,79 @@ +"""Bootstrap admin user on backend startup.""" + +from __future__ import annotations + +import pytest +from sqlalchemy import delete, select + +from cyclone.auth import bootstrap, users +from cyclone.auth.deps import AUTH_DISABLED +from cyclone.auth.permissions import Role +from cyclone.db import Session as DbSession +from cyclone.db import SessionLocal, User + + +@pytest.fixture(autouse=True) +def _clear(monkeypatch): + # Reset the bootstrap-side AUTH_DISABLED flag so tests don't leak state + # into each other. The conftest fixture flips this back to True at the + # start of every test, but bootstrap.run() mutates this module-level + # value, so we restore it here too. + monkeypatch.setattr("cyclone.auth.deps.AUTH_DISABLED", False) + # conftest sets CYCLONE_AUTH_DISABLED=1 at module import. Drop it + # by default so tests exercise the real bootstrap path; the + # AUTH_DISABLED-specific test re-sets it explicitly. + monkeypatch.delenv("CYCLONE_AUTH_DISABLED", raising=False) + with SessionLocal()() as db: + db.execute(delete(DbSession)) + db.execute(delete(User)) + db.commit() + yield + monkeypatch.setattr("cyclone.auth.deps.AUTH_DISABLED", False) + with SessionLocal()() as db: + db.execute(delete(DbSession)) + db.execute(delete(User)) + db.commit() + + +def test_bootstrap_creates_admin_when_users_empty_and_env_set(monkeypatch): + monkeypatch.setenv("CYCLONE_ADMIN_USERNAME", "firstadmin") + monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD", "firstadminpw1") + bootstrap.run() + with SessionLocal()() as db: + u = db.execute(select(User).where(User.username == "firstadmin")).scalar_one() + assert u.role == Role.ADMIN.value + + +def test_bootstrap_noop_when_users_exist(monkeypatch): + monkeypatch.setenv("CYCLONE_ADMIN_USERNAME", "ignored") + monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD", "ignoredignored1") + with SessionLocal()() as db: + users.create(db, username="existing", password="hunter2hunter2", role="admin") + bootstrap.run() + with SessionLocal()() as db: + all_users = db.execute(select(User)).scalars().all() + usernames = {u.username for u in all_users} + assert usernames == {"existing"} + + +def test_bootstrap_refuses_to_run_without_env(monkeypatch): + monkeypatch.delenv("CYCLONE_ADMIN_USERNAME", raising=False) + monkeypatch.delenv("CYCLONE_ADMIN_PASSWORD", raising=False) + with pytest.raises(RuntimeError, match="CYCLONE_ADMIN_USERNAME"): + bootstrap.run() + + +def test_bootstrap_rejects_short_password(monkeypatch): + monkeypatch.setenv("CYCLONE_ADMIN_USERNAME", "weak") + monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD", "short") + with pytest.raises(RuntimeError, match="12 characters"): + bootstrap.run() + + +def test_bootstrap_skips_when_auth_disabled(monkeypatch): + monkeypatch.setenv("CYCLONE_AUTH_DISABLED", "1") + # Even without env vars, bootstrap should NOT raise when AUTH_DISABLED=1. + bootstrap.run() + # And it must flip the deps flag so the API skips auth checks. + from cyclone.auth import deps as _deps + assert _deps.AUTH_DISABLED is True