Skip to content

Commit 418f490

Browse files
authored
Merge pull request #60 from luis5tb/dcr-enc-key
fix: fail-fast on missing or invalid DCR_ENCRYPTION_KEY
2 parents 60c5a00 + 9b3b497 commit 418f490

4 files changed

Lines changed: 75 additions & 4 deletions

File tree

src/lightspeed_agent/dcr/service.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import logging
6+
import os
67
from typing import TYPE_CHECKING
78

89
import httpx
@@ -73,13 +74,28 @@ def __init__(
7374
self._client_repository = client_repository or get_dcr_client_repository()
7475
self._settings = get_settings()
7576

76-
# Fernet cipher for encrypting client secrets
77+
# Fernet cipher for encrypting client secrets.
78+
# In production (Cloud Run), DCR_ENCRYPTION_KEY is required to prevent
79+
# silent data loss from missing encryption configuration.
7780
self._fernet: Fernet | None = None
7881
if self._settings.dcr_encryption_key:
7982
try:
8083
self._fernet = Fernet(self._settings.dcr_encryption_key.encode())
8184
except Exception as e:
82-
logger.error("Invalid DCR encryption key: %s", e)
85+
raise ValueError(
86+
f"Invalid DCR_ENCRYPTION_KEY: {e}. "
87+
"Generate a valid key with: "
88+
"python -c 'from cryptography.fernet import Fernet; "
89+
"print(Fernet.generate_key().decode())'"
90+
) from e
91+
elif os.getenv("K_SERVICE"):
92+
raise ValueError(
93+
"DCR_ENCRYPTION_KEY is required in production "
94+
f"(K_SERVICE={os.getenv('K_SERVICE')}). "
95+
"Client secrets cannot be stored without an encryption key. "
96+
"Generate a key with: python -c 'from cryptography.fernet import Fernet; "
97+
"print(Fernet.generate_key().decode())'"
98+
)
8399

84100
def _get_gma_client(self) -> GMAClient:
85101
"""Get the GMA client (lazy initialization)."""
@@ -95,10 +111,15 @@ def _encrypt_secret(self, secret: str) -> str:
95111
96112
Returns:
97113
Encrypted secret as base64 string.
114+
115+
Raises:
116+
RuntimeError: If DCR_ENCRYPTION_KEY is not configured.
98117
"""
99118
if not self._fernet:
100-
logger.warning("DCR_ENCRYPTION_KEY not set, using ephemeral key")
101-
self._fernet = Fernet(Fernet.generate_key())
119+
raise RuntimeError(
120+
"Cannot encrypt client secret: DCR_ENCRYPTION_KEY is not configured. "
121+
"Set DCR_ENCRYPTION_KEY to a valid Fernet key before performing DCR operations."
122+
)
102123
return self._fernet.encrypt(secret.encode()).decode()
103124

104125
def _decrypt_secret(self, encrypted_secret: str) -> str | None:

src/lightspeed_agent/marketplace/app.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,19 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
3737
logger.error("Failed to initialize database: %s", e)
3838
raise
3939

40+
# Startup: Validate DCR configuration (fail-fast on Cloud Run)
41+
# This ensures DCR_ENCRYPTION_KEY is valid BEFORE the service becomes ready,
42+
# preventing silent failures when the first DCR request arrives.
43+
try:
44+
from lightspeed_agent.dcr import get_dcr_service
45+
46+
logger.info("Validating DCR service configuration")
47+
get_dcr_service() # Triggers DCRService.__init__() validation
48+
logger.info("DCR service initialized successfully")
49+
except Exception as e:
50+
logger.error("Failed to initialize DCR service: %s", e)
51+
raise
52+
4053
yield
4154

4255
# Shutdown: Close database connection

tests/conftest.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818
os.environ["DCR_ENABLED"] = "false" # Use pre-seeded credentials for tests
1919
os.environ["RED_HAT_SSO_CLIENT_ID"] = "test-static-client-id"
2020
os.environ["RED_HAT_SSO_CLIENT_SECRET"] = "test-static-client-secret"
21+
# Stable Fernet key for tests — secrets encrypted in one test can be decrypted in another
22+
from cryptography.fernet import Fernet as _Fernet # noqa: E402
23+
24+
os.environ["DCR_ENCRYPTION_KEY"] = _Fernet.generate_key().decode()
2125

2226

2327
@pytest.fixture

tests/test_dcr.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,39 @@ async def test_get_client(self, service):
277277
assert client.account_id == "valid-account-123"
278278

279279

280+
class TestDCRServiceEncryptionValidation:
281+
"""Tests for DCR service encryption key validation."""
282+
283+
def test_dcr_service_missing_key_on_cloud_run(self, monkeypatch, db_session):
284+
"""Test DCRService raises ValueError when DCR_ENCRYPTION_KEY is missing on Cloud Run."""
285+
from lightspeed_agent.config import get_settings
286+
287+
settings = get_settings()
288+
monkeypatch.setenv("K_SERVICE", "test-marketplace-handler")
289+
monkeypatch.setattr(settings, "dcr_encryption_key", "")
290+
291+
with pytest.raises(ValueError, match="DCR_ENCRYPTION_KEY is required in production"):
292+
DCRService()
293+
294+
def test_dcr_service_invalid_encryption_key(self, monkeypatch, db_session):
295+
"""Test that DCRService raises ValueError for invalid DCR_ENCRYPTION_KEY."""
296+
from lightspeed_agent.config import get_settings
297+
298+
settings = get_settings()
299+
monkeypatch.setattr(settings, "dcr_encryption_key", "not-a-valid-fernet-key")
300+
301+
with pytest.raises(ValueError, match="Invalid DCR_ENCRYPTION_KEY"):
302+
DCRService()
303+
304+
def test_encrypt_secret_without_key_raises(self, db_session):
305+
"""Test that _encrypt_secret raises RuntimeError when _fernet is None."""
306+
service = DCRService()
307+
service._fernet = None
308+
309+
with pytest.raises(RuntimeError, match="Cannot encrypt client secret"):
310+
service._encrypt_secret("test-secret")
311+
312+
280313
class TestDCRServiceDelete:
281314
"""Tests for DCR service client deletion."""
282315

0 commit comments

Comments
 (0)