-
Notifications
You must be signed in to change notification settings - Fork 28
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: read secrets from paths (#425)
* chore: added dev dependencies * feat: added get_secrets * feat: use get_secret instead of os.getenv for sensitive configs * feat: use get_secret in github_callback for github client secret * feat: added conftest * feat: added tests * feat: run tests in ci * feat: added secret config util * feat: added secret config util test * feat: use getSecret to initialize sensitive secrets * feat: use get_secret for license * chore: update tests * fix: tests --------- Co-authored-by: Rohan <[email protected]>
- Loading branch information
1 parent
f8d787d
commit 5335f49
Showing
10 changed files
with
510 additions
and
41 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,62 @@ | ||
import os | ||
from pathlib import Path | ||
import logging | ||
|
||
logger = logging.getLogger(__name__) | ||
|
||
|
||
def get_secret(key: str) -> str | None: | ||
""" | ||
Retrieve secrets from either files or environment variables. Implements a "secrets provider" pattern | ||
commonly used in containerized applications. | ||
1. Check if {key}_FILE exists as an environment variable (e.g. PGSQL_PASSWORD_FILE) | ||
- If it exists, read the secret from that file location | ||
- This supports Docker/Kubernetes secrets (https://docs.docker.com/reference/compose-file/secrets) | ||
2. Fall back to checking if {key} exists as a regular environment variable | ||
Example: | ||
# Using file-based secret: | ||
DATABASE_PASSWORD_FILE=/run/secrets/db_password | ||
get_secret('DATABASE_PASSWORD') # reads from /run/secrets/db_password | ||
# Using environment variable: | ||
DATABASE_PASSWORD=ebeefa2b4634ab18b0280c96fce1adc5969dcad133cce440353b5ed1a7387f0a | ||
get_secret('DATABASE_PASSWORD') # returns 'ebeefa2b4634ab18b0280c96fce1adc5969dcad133cce440353b5ed1a7387f0a' | ||
Args: | ||
key: Name of the secret to retrieve (e.g. 'DATABASE_PASSWORD') | ||
Returns: | ||
str: The secret value or None if not found | ||
""" | ||
|
||
debug_mode = os.getenv("DEBUG", "False").lower() == "true" | ||
|
||
file_env_key = f"{key}_FILE" | ||
file_path = os.getenv(file_env_key) | ||
|
||
if file_path: | ||
path = Path(file_path) | ||
if path.exists(): | ||
try: | ||
secret = path.read_text().strip() | ||
if debug_mode: | ||
logger.debug(f"Loaded secret '{key}' from file: {file_path}") | ||
return secret | ||
except (PermissionError, OSError) as e: | ||
if debug_mode: | ||
logger.debug(f"Failed to read secret file for '{key}': {e}") | ||
elif debug_mode: | ||
logger.debug( | ||
f"File path specified for '{key}' but file not found: {file_path}" | ||
) | ||
|
||
secret = os.getenv(key, None) | ||
if debug_mode: | ||
if secret: | ||
logger.debug(f"Loaded secret '{key}' from environment variable") | ||
else: | ||
logger.debug(f"Secret '{key}' not found in environment or file") | ||
|
||
return secret |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
pytest==8.3.4 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
import os | ||
import sys | ||
from pathlib import Path | ||
|
||
# Add the project root directory to Python path | ||
project_root = Path(__file__).parent.parent | ||
sys.path.append(str(project_root)) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,170 @@ | ||
import os | ||
import pytest | ||
from pathlib import Path | ||
import logging | ||
from unittest.mock import patch | ||
from backend.utils.secrets import get_secret | ||
|
||
|
||
@pytest.fixture | ||
def temp_secret_file(tmp_path): | ||
"""Create a temporary file containing a secret""" | ||
secret_file = tmp_path / "secret.txt" | ||
secret_file.write_text("file_secret_value") | ||
return secret_file | ||
|
||
|
||
@pytest.fixture | ||
def caplog_debug(caplog): | ||
"""Configure logging to capture debug messages""" | ||
caplog.set_level(logging.DEBUG) | ||
return caplog | ||
|
||
|
||
def test_get_secret_from_environment(): | ||
"""Test retrieving secret from environment variable""" | ||
with patch.dict(os.environ, {"TEST_SECRET": "env_secret_value"}): | ||
assert get_secret("TEST_SECRET") == "env_secret_value" | ||
|
||
|
||
def test_get_secret_from_file(temp_secret_file): | ||
"""Test retrieving secret from file when _FILE env var is set""" | ||
with patch.dict( | ||
os.environ, | ||
{ | ||
"TEST_SECRET_FILE": str(temp_secret_file), | ||
}, | ||
): | ||
assert get_secret("TEST_SECRET") == "file_secret_value" | ||
|
||
|
||
def test_get_secret_file_priority(temp_secret_file): | ||
"""Test that file-based secret takes priority over environment variable""" | ||
with patch.dict( | ||
os.environ, | ||
{ | ||
"TEST_SECRET": "env_secret_value", | ||
"TEST_SECRET_FILE": str(temp_secret_file), | ||
}, | ||
): | ||
assert get_secret("TEST_SECRET") == "file_secret_value" | ||
|
||
|
||
def test_get_secret_missing_file(): | ||
"""Test fallback to env var when file doesn't exist""" | ||
with patch.dict( | ||
os.environ, | ||
{ | ||
"TEST_SECRET": "env_secret_value", | ||
"TEST_SECRET_FILE": "/nonexistent/path", | ||
}, | ||
): | ||
assert get_secret("TEST_SECRET") == "env_secret_value" | ||
|
||
|
||
def test_get_secret_neither_exists(): | ||
"""Test None returned when neither file nor env var exists""" | ||
with patch.dict(os.environ, {}, clear=True): | ||
assert get_secret("TEST_SECRET") == None | ||
|
||
|
||
def test_get_secret_empty_file(tmp_path): | ||
"""Test handling of empty secret file""" | ||
empty_file = tmp_path / "empty_secret.txt" | ||
empty_file.write_text("") | ||
|
||
with patch.dict( | ||
os.environ, | ||
{ | ||
"TEST_SECRET_FILE": str(empty_file), | ||
}, | ||
): | ||
assert get_secret("TEST_SECRET") == "" | ||
|
||
|
||
def test_debug_logging_file_success(temp_secret_file, caplog_debug): | ||
"""Test debug logging when secret is successfully loaded from file""" | ||
with patch.dict( | ||
os.environ, | ||
{ | ||
"DEBUG": "true", | ||
"TEST_SECRET_FILE": str(temp_secret_file), | ||
}, | ||
): | ||
get_secret("TEST_SECRET") | ||
assert ( | ||
f"Loaded secret 'TEST_SECRET' from file: {temp_secret_file}" | ||
in caplog_debug.text | ||
) | ||
|
||
|
||
def test_debug_logging_file_not_found(caplog_debug): | ||
"""Test debug logging when secret file is not found""" | ||
with patch.dict( | ||
os.environ, | ||
{ | ||
"DEBUG": "true", | ||
"TEST_SECRET_FILE": "/nonexistent/path", | ||
}, | ||
): | ||
get_secret("TEST_SECRET") | ||
assert ( | ||
"File path specified for 'TEST_SECRET' but file not found" | ||
in caplog_debug.text | ||
) | ||
|
||
|
||
def test_debug_logging_env_var_success(caplog_debug): | ||
"""Test debug logging when secret is successfully loaded from env var""" | ||
with patch.dict( | ||
os.environ, | ||
{ | ||
"DEBUG": "true", | ||
"TEST_SECRET": "env_secret_value", | ||
}, | ||
): | ||
get_secret("TEST_SECRET") | ||
assert ( | ||
"Loaded secret 'TEST_SECRET' from environment variable" in caplog_debug.text | ||
) | ||
|
||
|
||
def test_debug_logging_not_found(caplog_debug): | ||
"""Test debug logging when secret is not found anywhere""" | ||
with patch.dict( | ||
os.environ, | ||
{ | ||
"DEBUG": "true", | ||
}, | ||
): | ||
get_secret("TEST_SECRET") | ||
assert ( | ||
"Secret 'TEST_SECRET' not found in environment or file" in caplog_debug.text | ||
) | ||
|
||
|
||
def test_secret_file_with_whitespace(tmp_path): | ||
"""Test handling of secret files with whitespace""" | ||
secret_file = tmp_path / "secret_with_whitespace.txt" | ||
secret_file.write_text(" secret_value_with_spaces \n") | ||
|
||
with patch.dict( | ||
os.environ, | ||
{ | ||
"TEST_SECRET_FILE": str(secret_file), | ||
}, | ||
): | ||
assert get_secret("TEST_SECRET") == "secret_value_with_spaces" | ||
|
||
|
||
def test_file_read_permission_error(tmp_path): | ||
"""Test handling of unreadable secret file""" | ||
secret_file = tmp_path / "unreadable_secret.txt" | ||
secret_file.write_text("secret_value") | ||
secret_file.chmod(0o000) # Remove all permissions | ||
|
||
with patch.dict( | ||
os.environ, | ||
{"TEST_SECRET_FILE": str(secret_file), "TEST_SECRET": "fallback_value"}, | ||
): | ||
assert get_secret("TEST_SECRET") == "fallback_value" |
Oops, something went wrong.