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:
@@ -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