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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user