diff --git a/backend/src/cyclone/auth/bootstrap.py b/backend/src/cyclone/auth/bootstrap.py index 3a9b036..de568fe 100644 --- a/backend/src/cyclone/auth/bootstrap.py +++ b/backend/src/cyclone/auth/bootstrap.py @@ -11,6 +11,11 @@ Precedence: 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. + 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 points operators at ``python -m cyclone users create``. """ @@ -18,6 +23,7 @@ Precedence: from __future__ import annotations import os +from pathlib import Path from sqlalchemy import select @@ -27,6 +33,21 @@ from cyclone.auth.permissions import Role 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: """Bootstrap the first admin user, or no-op. @@ -42,8 +63,12 @@ def run() -> None: _deps.AUTH_DISABLED = True return - username = os.environ.get("CYCLONE_ADMIN_USERNAME") - password = os.environ.get("CYCLONE_ADMIN_PASSWORD") + username = _read_secret( + "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 # subcommand or the FastAPI lifespan handler runs, so on a brand-new diff --git a/backend/tests/test_auth_bootstrap_file.py b/backend/tests/test_auth_bootstrap_file.py new file mode 100644 index 0000000..446b179 --- /dev/null +++ b/backend/tests/test_auth_bootstrap_file.py @@ -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/`` 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()