feat(sp23): auth bootstrap reads CYCLONE_ADMIN_*_FILE for Docker secrets

Add _read_secret() helper that prefers *_FILE env vars (the standard Docker-secret pattern) over bare env vars. Strips trailing whitespace from file contents so printf/echo newlines don't break bcrypt verify. Existing CYCLONE_ADMIN_USERNAME + CYCLONE_ADMIN_PASSWORD still work for non-Docker deployments. Add tests/test_auth_bootstrap_file.py covering: file-path read, file-overrides-bare, bare-fallback, whitespace-stripping, missing-file raises.
This commit is contained in:
Cyclone
2026-06-23 17:24:23 -06:00
parent 256ddfa5fa
commit 7706a6d7fe
2 changed files with 158 additions and 2 deletions
+27 -2
View File
@@ -11,6 +11,11 @@ Precedence:
2. Users table non-empty — no-op. 2. Users table non-empty — no-op.
3. ``CYCLONE_ADMIN_USERNAME`` + ``CYCLONE_ADMIN_PASSWORD`` env vars set 3. ``CYCLONE_ADMIN_USERNAME`` + ``CYCLONE_ADMIN_PASSWORD`` env vars set
(password >= 12 chars) — create the admin and print confirmation. (password >= 12 chars) — create the admin and print confirmation.
Each env var can also be replaced by a ``*_FILE`` companion
(``CYCLONE_ADMIN_USERNAME_FILE`` / ``CYCLONE_ADMIN_PASSWORD_FILE``)
that points at a file on disk — the standard Docker-secret pattern,
used in production to avoid embedding secrets in ``docker-compose.yml``.
``_FILE`` takes precedence when set.
4. Otherwise — raise ``RuntimeError`` with a remediation hint that 4. Otherwise — raise ``RuntimeError`` with a remediation hint that
points operators at ``python -m cyclone users create``. points operators at ``python -m cyclone users create``.
""" """
@@ -18,6 +23,7 @@ Precedence:
from __future__ import annotations from __future__ import annotations
import os import os
from pathlib import Path
from sqlalchemy import select from sqlalchemy import select
@@ -27,6 +33,21 @@ from cyclone.auth.permissions import Role
from cyclone.db import SessionLocal, User from cyclone.db import SessionLocal, User
def _read_secret(env_var: str, file_var: str) -> str | None:
"""Read a secret from a ``*_FILE`` env var (Docker-secret pattern) first,
falling back to the plain env var. Returns None if neither is set.
"""
file_path = os.environ.get(file_var)
if file_path:
try:
return Path(file_path).read_text().strip()
except OSError as exc:
raise RuntimeError(
f"failed to read {file_var}={file_path}: {exc}"
) from exc
return os.environ.get(env_var)
def run() -> None: def run() -> None:
"""Bootstrap the first admin user, or no-op. """Bootstrap the first admin user, or no-op.
@@ -42,8 +63,12 @@ def run() -> None:
_deps.AUTH_DISABLED = True _deps.AUTH_DISABLED = True
return return
username = os.environ.get("CYCLONE_ADMIN_USERNAME") username = _read_secret(
password = os.environ.get("CYCLONE_ADMIN_PASSWORD") "CYCLONE_ADMIN_USERNAME", "CYCLONE_ADMIN_USERNAME_FILE"
)
password = _read_secret(
"CYCLONE_ADMIN_PASSWORD", "CYCLONE_ADMIN_PASSWORD_FILE"
)
# First-boot fix: ``python -m cyclone`` calls bootstrap before any # First-boot fix: ``python -m cyclone`` calls bootstrap before any
# subcommand or the FastAPI lifespan handler runs, so on a brand-new # subcommand or the FastAPI lifespan handler runs, so on a brand-new
+131
View File
@@ -0,0 +1,131 @@
"""Auth bootstrap reads CYCLONE_ADMIN_*_FILE env vars when set.
Mirrors the Docker-secret posture in ``docker-compose.yml``: secrets
mounted at ``/run/secrets/<name>`` with the ``*_FILE`` env var pointing
at the path. The bootstrap should prefer the file over the bare env var
when both are present (file = Docker secret wins).
"""
from __future__ import annotations
from pathlib import Path
import pytest
from cyclone import db
from cyclone.auth import bootstrap, users
from cyclone.auth.permissions import Role
@pytest.fixture
def tmp_db(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
db_path = tmp_path / "test.db"
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{db_path}")
db.init_db()
return db_path
def _write_secrets(tmp_path: Path, *, username: str, password: str) -> tuple[Path, Path]:
user_file = tmp_path / "admin_username"
pw_file = tmp_path / "admin_pw"
user_file.write_text(f"{username}\n")
pw_file.write_text(f"{password}\n")
return user_file, pw_file
def test_bootstrap_creates_admin_from_file_env_vars(
tmp_path: Path, tmp_db: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
user_file, pw_file = _write_secrets(
tmp_path, username="deploy-admin", password="super-secret-password-123"
)
monkeypatch.delenv("CYCLONE_ADMIN_USERNAME", raising=False)
monkeypatch.delenv("CYCLONE_ADMIN_PASSWORD", raising=False)
monkeypatch.setenv("CYCLONE_ADMIN_USERNAME_FILE", str(user_file))
monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD_FILE", str(pw_file))
bootstrap.run()
with db.SessionLocal()() as session:
user = users.get_by_username(session, "deploy-admin")
assert user is not None
assert user.role == Role.ADMIN.value
assert users.verify_password("super-secret-password-123", user.password_hash)
def test_file_env_var_overrides_plain_env_var(
tmp_path: Path, tmp_db: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""When both _FILE and bare env vars are set, _FILE wins."""
user_file, pw_file = _write_secrets(
tmp_path, username="file-user", password="file-password-12345"
)
monkeypatch.setenv("CYCLONE_ADMIN_USERNAME", "env-user")
monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD", "env-password-12345")
monkeypatch.setenv("CYCLONE_ADMIN_USERNAME_FILE", str(user_file))
monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD_FILE", str(pw_file))
bootstrap.run()
with db.SessionLocal()() as session:
file_user = users.get_by_username(session, "file-user")
env_user = users.get_by_username(session, "env-user")
assert file_user is not None
assert env_user is None, "bare env var should be ignored when _FILE is set"
def test_bootstrap_falls_back_to_plain_env_var(
tmp_path: Path, tmp_db: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("CYCLONE_ADMIN_USERNAME_FILE", raising=False)
monkeypatch.delenv("CYCLONE_ADMIN_PASSWORD_FILE", raising=False)
monkeypatch.setenv("CYCLONE_ADMIN_USERNAME", "env-user")
monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD", "env-password-12345")
bootstrap.run()
with db.SessionLocal()() as session:
user = users.get_by_username(session, "env-user")
assert user is not None
assert user.role == Role.ADMIN.value
def test_bootstrap_strips_trailing_whitespace_from_file(
tmp_path: Path, tmp_db: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Secret files often have a trailing newline from `printf`/`echo`.
The bootstrap must strip it so bcrypt verify doesn't see whitespace."""
user_file = tmp_path / "admin_username"
pw_file = tmp_path / "admin_pw"
user_file.write_text("deploy-admin\n\n")
pw_file.write_text("super-secret-password-123\n")
monkeypatch.delenv("CYCLONE_ADMIN_USERNAME", raising=False)
monkeypatch.delenv("CYCLONE_ADMIN_PASSWORD", raising=False)
monkeypatch.setenv("CYCLONE_ADMIN_USERNAME_FILE", str(user_file))
monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD_FILE", str(pw_file))
bootstrap.run()
with db.SessionLocal()() as session:
user = users.get_by_username(session, "deploy-admin")
assert user is not None
# Should verify cleanly with no trailing whitespace.
assert users.verify_password("super-secret-password-123", user.password_hash)
assert not users.verify_password(
"super-secret-password-123\n", user.password_hash
)
def test_bootstrap_raises_when_file_path_missing(
tmp_path: Path, tmp_db: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("CYCLONE_ADMIN_USERNAME", raising=False)
monkeypatch.delenv("CYCLONE_ADMIN_PASSWORD", raising=False)
monkeypatch.setenv(
"CYCLONE_ADMIN_USERNAME_FILE", str(tmp_path / "does-not-exist")
)
monkeypatch.setenv(
"CYCLONE_ADMIN_PASSWORD_FILE", str(tmp_path / "also-missing")
)
with pytest.raises(RuntimeError, match="failed to read"):
bootstrap.run()