feat(auth): bootstrap first admin from env vars

This commit is contained in:
Nora
2026-06-22 14:52:51 -06:00
parent 86b635104c
commit 768f7c6247
3 changed files with 157 additions and 0 deletions
+8
View File
@@ -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"
+70
View File
@@ -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")