From ccb3f3fb7ed0e800c8c406015e86d74d7d807f70 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 29 Mar 2026 23:10:36 +0000 Subject: [PATCH] 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> --- .env.example | 6 +++++ backend/app/core/config.py | 17 ++++++++++++++ backend/app/main.py | 39 ++++++++++++++++++++------------ backend/app/tests/test_config.py | 32 ++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 15 deletions(-) diff --git a/.env.example b/.env.example index 0e4ec45..13ff4e5 100644 --- a/.env.example +++ b/.env.example @@ -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" diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 0661b46..9530d1c 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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.""" diff --git a/backend/app/main.py b/backend/app/main.py index 8312082..5edb823 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 diff --git a/backend/app/tests/test_config.py b/backend/app/tests/test_config.py index 7fd2385..fd7d4e0 100644 --- a/backend/app/tests/test_config.py +++ b/backend/app/tests/test_config.py @@ -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