feat(auth): bootstrap first admin from env vars
This commit is contained in:
@@ -16,6 +16,14 @@ import sys
|
|||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
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":
|
if len(sys.argv) >= 2 and sys.argv[1] == "serve":
|
||||||
port = os.environ.get("CYCLONE_PORT", "8000")
|
port = os.environ.get("CYCLONE_PORT", "8000")
|
||||||
reload = os.environ.get("CYCLONE_RELOAD", "0") == "1"
|
reload = os.environ.get("CYCLONE_RELOAD", "0") == "1"
|
||||||
|
|||||||
@@ -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 <username> --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")
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user