Allow admin API key to be configured via ADMIN_API_KEY env var

Agent-Logs-Url: https://github.com/christianlouis/dmarq/sessions/27db6d87-70db-4979-a23c-dd376f1c3b9a

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-29 23:10:36 +00:00
parent f2dd2333b6
commit ccb3f3fb7e
4 changed files with 79 additions and 15 deletions
+6
View File
@@ -11,6 +11,12 @@ PROJECT_NAME="DMARQ"
# NEVER use the default value in production!
SECRET_KEY="CHANGE_THIS_TO_A_RANDOM_SECRET_IN_PRODUCTION"
# Admin API Key for the X-API-Key header (optional)
# If set, this key is used instead of generating a random one at startup.
# Generate with: openssl rand -hex 32
# If not set, a random key is generated each restart (logged with last 8 chars).
# ADMIN_API_KEY="your_admin_api_key_here"
# Environment (development/production)
# Affects HSTS and other security settings
ENVIRONMENT="development"
+17
View File
@@ -49,6 +49,23 @@ class Settings(BaseSettings):
CLOUDFLARE_API_TOKEN: Optional[str] = None
CLOUDFLARE_ZONE_ID: Optional[str] = None
# Admin API Key (optional)
# If set, this key is used directly instead of generating a random one at startup.
# Use: openssl rand -hex 32
ADMIN_API_KEY: Optional[str] = None
@validator("ADMIN_API_KEY", pre=True, always=True)
def validate_admin_api_key(cls, v: Optional[str]) -> Optional[str]: # pylint: disable=no-self-argument
"""Warn if ADMIN_API_KEY is set but too short."""
if v is not None and len(v) < 32:
logger.warning(
"ADMIN_API_KEY is too short (%s characters). "
"Recommended minimum is 32 characters for security. "
"Generate a strong key with: openssl rand -hex 32",
len(v),
)
return v or None
@validator("SECRET_KEY", pre=True, always=True)
def validate_secret_key(cls, v: Optional[str]) -> str: # pylint: disable=no-self-argument
"""Validate and generate SECRET_KEY if not provided."""
+24 -15
View File
@@ -295,21 +295,30 @@ def create_app() -> FastAPI:
# Ensure all tables exist (no-op if already present)
Base.metadata.create_all(bind=engine)
# Generate and provide admin API key
api_key = generate_api_key()
add_api_key(api_key)
# Security: Log only last 8 characters for reference
logger.warning(
"%s\nIMPORTANT: Admin API Key Generated\n"
"API Key (last 8 chars): ...%s\n"
"Full key stored securely in memory.\n"
"For production, retrieve the key through secure configuration management.\n"
"Use this key in the X-API-Key header for admin endpoints.\n%s",
"=" * 80,
api_key[-8:],
"=" * 80,
)
# Load or generate the admin API key
if settings.ADMIN_API_KEY:
api_key = settings.ADMIN_API_KEY
add_api_key(api_key)
key_suffix = api_key[-8:] if len(api_key) >= 8 else api_key
logger.info(
"Admin API key loaded from ADMIN_API_KEY environment variable "
"(ends with: ...%s).",
key_suffix,
)
else:
api_key = generate_api_key()
add_api_key(api_key)
# Security: Log only last 8 characters for reference
logger.warning(
"%s\nIMPORTANT: Admin API Key Generated\n"
"API Key (last 8 chars): ...%s\n"
"Full key stored securely in memory.\n"
"Set ADMIN_API_KEY in your environment to use a fixed key across restarts.\n"
"Use this key in the X-API-Key header for admin endpoints.\n%s",
"=" * 80,
api_key[-8:],
"=" * 80,
)
# One-time migration: if IMAP_* env vars are set and no mail sources exist,
# create an initial MailSource from those settings so existing deployments
+32
View File
@@ -138,3 +138,35 @@ class TestEnsureSqliteDir:
"""Default DATABASE_URL places the SQLite file inside a data/ subdirectory."""
settings = Settings()
assert settings.DATABASE_URL.endswith("data/dmarq.db")
class TestAdminApiKeySetting:
"""Tests for the ADMIN_API_KEY settings field."""
def test_admin_api_key_defaults_to_none(self):
"""ADMIN_API_KEY is None when not set."""
settings = Settings()
assert settings.ADMIN_API_KEY is None
def test_admin_api_key_reads_from_env(self, monkeypatch):
"""ADMIN_API_KEY is read from the environment variable."""
monkeypatch.setenv("ADMIN_API_KEY", "mytestapikey1234")
settings = Settings()
assert settings.ADMIN_API_KEY == "mytestapikey1234"
def test_admin_api_key_warns_when_short(self, monkeypatch, caplog):
"""A warning is logged when ADMIN_API_KEY is shorter than 32 characters."""
import logging
monkeypatch.setenv("ADMIN_API_KEY", "short")
with caplog.at_level(logging.WARNING, logger="app.core.config"):
settings = Settings()
assert settings.ADMIN_API_KEY == "short"
assert any("too short" in record.message for record in caplog.records)
def test_admin_api_key_accepts_long_key(self, monkeypatch):
"""A 64-char hex key (openssl rand -hex 32 output) is accepted without warnings."""
long_key = "a" * 64
monkeypatch.setenv("ADMIN_API_KEY", long_key)
settings = Settings()
assert settings.ADMIN_API_KEY == long_key