-
Notifications
You must be signed in to change notification settings - Fork 10
Infra: Introduce AWS Secrets Manager #778
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
d0ea376
3384abb
82d0c37
1626027
8287a02
be94d3d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,6 @@ | ||
| import boto3 | ||
| import json | ||
| import logging | ||
| import secrets | ||
| import warnings | ||
| import os | ||
|
Comment on lines
+2
to
6
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we can order this alphabetically
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done |
||
|
|
@@ -12,9 +15,14 @@ | |
| model_validator, | ||
| ) | ||
| from pydantic_core import MultiHostUrl | ||
| from pydantic_settings import BaseSettings, SettingsConfigDict | ||
| from pydantic_settings import ( | ||
| BaseSettings, | ||
| SettingsConfigDict, | ||
| ) | ||
| from typing_extensions import Self | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| def parse_cors(origins: Any) -> list[str] | str: | ||
| # If it's a plain comma-separated string, split it into a list | ||
|
|
@@ -46,9 +54,9 @@ class Settings(BaseSettings): | |
| PROJECT_NAME: str | ||
| API_VERSION: str = "0.5.0" | ||
| SENTRY_DSN: HttpUrl | None = None | ||
| POSTGRES_SERVER: str | ||
| POSTGRES_SERVER: str = "" | ||
| POSTGRES_PORT: int = 5432 | ||
| POSTGRES_USER: str | ||
| POSTGRES_USER: str = "" | ||
| POSTGRES_PASSWORD: str = "" | ||
| POSTGRES_DB: str = "" | ||
|
Comment on lines
+57
to
61
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fail fast when required Postgres settings remain empty. Making 🛡️ Proposed fix `@model_validator`(mode="after")
def _enforce_non_default_secrets(self) -> Self:
+ if self.ENVIRONMENT in {"staging", "production"}:
+ required_db_settings = {
+ "POSTGRES_SERVER": self.POSTGRES_SERVER,
+ "POSTGRES_USER": self.POSTGRES_USER,
+ "POSTGRES_PASSWORD": self.POSTGRES_PASSWORD,
+ "POSTGRES_DB": self.POSTGRES_DB,
+ }
+ missing = [
+ name for name, value in required_db_settings.items() if not value
+ ]
+ if missing:
+ raise ValueError(
+ f"Missing required database settings: {', '.join(missing)}"
+ )
+
self._check_default_secret("SECRET_KEY", self.SECRET_KEY)
self._check_default_secret("POSTGRES_PASSWORD", self.POSTGRES_PASSWORD)
self._check_default_secret(Also applies to: 250-258 🤖 Prompt for AI Agents |
||
| KAAPI_GUARDRAILS_AUTH: str = "" | ||
|
|
@@ -104,6 +112,11 @@ def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn: | |
| AWS_DEFAULT_REGION: str = "" | ||
| AWS_S3_BUCKET_PREFIX: str = "" | ||
|
|
||
| # AWS Secrets Manager | ||
| USE_AWS_SECRETS: bool = False | ||
| AWS_SECRETS_REGION: str = "" | ||
| AWS_POSTGRES_SECRET_NAME: str = "" | ||
|
|
||
| # RabbitMQ configuration for Celery broker | ||
| RABBITMQ_HOST: str = "localhost" | ||
| RABBITMQ_PORT: int = 5672 | ||
|
|
@@ -182,6 +195,64 @@ def _check_default_secret(self, var_name: str, value: str | None) -> None: | |
| else: | ||
| raise ValueError(message) | ||
|
|
||
| @model_validator(mode="after") | ||
| def _apply_aws_secrets(self) -> Self: | ||
| """Overlay service credentials from AWS Secrets Manager when enabled. | ||
|
|
||
| Each AWS_*_SECRET_NAME points to a secret whose SecretString is a | ||
| JSON object (e.g. the format produced by RDS/ElastiCache rotation). | ||
| Each secret is processed independently with its own field map so | ||
| services that share key names (username/password/host/port) do not | ||
| overwrite each other. Keys absent from a secret leave the existing | ||
| .env-derived value untouched. | ||
| """ | ||
| if not self.USE_AWS_SECRETS: | ||
| return self | ||
|
|
||
| secret_configs: list[tuple[str, dict[str, str]]] = [ | ||
| ( | ||
| self.AWS_POSTGRES_SECRET_NAME, | ||
| { | ||
| "username": "POSTGRES_USER", | ||
| "password": "POSTGRES_PASSWORD", | ||
| "host": "POSTGRES_SERVER", | ||
| "port": "POSTGRES_PORT", | ||
| }, | ||
| ), | ||
| ] | ||
|
|
||
| active = [(name, field_map) for name, field_map in secret_configs if name] | ||
| if not active: | ||
| return self | ||
|
Ayush8923 marked this conversation as resolved.
|
||
|
|
||
| region = self.AWS_SECRETS_REGION or self.AWS_DEFAULT_REGION | ||
| client_kwargs: dict[str, Any] = {"region_name": region} if region else {} | ||
| client = boto3.client("secretsmanager", **client_kwargs) | ||
|
|
||
| for secret_name, field_map in active: | ||
| self._load_secret(client, secret_name, field_map) | ||
|
|
||
| return self | ||
|
|
||
| def _load_secret( | ||
| self, client: Any, secret_name: str, field_map: dict[str, str] | ||
| ) -> None: | ||
| data = json.loads(client.get_secret_value(SecretId=secret_name)["SecretString"]) | ||
| if not isinstance(data, dict): | ||
| raise ValueError( | ||
| f"Secret '{secret_name}' must be a JSON object " | ||
| f"(got {type(data).__name__})" | ||
| ) | ||
| applied: list[str] = [] | ||
| for key, field in field_map.items(): | ||
| if key in data: | ||
| setattr(self, field, data[key]) | ||
|
Comment on lines
+247
to
+249
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: In Pydantic v2, validate_assignment=True (set via model_config = ConfigDict(validate_assignment=True)) enables validation on attribute assignment (setattr) after model initialization. By default, models are mutable, allowing instance.field = value without validation. When validate_assignment=True, assignment like instance.field = value triggers validation similar to initialization: the value passes through field validators (including type coercion, Citations:
🏁 Script executed: cat -n backend/app/core/config.py | head -250 | tail -50Repository: ProjectTech4DevAI/kaapi-backend Length of output: 2165 🏁 Script executed: wc -l backend/app/core/config.pyRepository: ProjectTech4DevAI/kaapi-backend Length of output: 105 🏁 Script executed: cat -n backend/app/core/config.pyRepository: ProjectTech4DevAI/kaapi-backend Length of output: 11070 Add validation and type coercion before assigning secret values to typed fields. Using Proposed fix applied: list[str] = []
for key, field in field_map.items():
if key in data:
- setattr(self, field, data[key])
+ value = data[key]
+ if field == "POSTGRES_PORT":
+ if isinstance(value, bool):
+ raise ValueError(
+ f"Secret '{secret_name}' key '{key}' must be an integer port"
+ )
+ try:
+ value = int(value)
+ except (TypeError, ValueError) as exc:
+ raise ValueError(
+ f"Secret '{secret_name}' key '{key}' must be an integer port"
+ ) from exc
+ if not 1 <= value <= 65535:
+ raise ValueError(
+ f"Secret '{secret_name}' key '{key}' must be a valid TCP port"
+ )
+ elif not isinstance(value, str):
+ raise ValueError(
+ f"Secret '{secret_name}' key '{key}' must be a string"
+ )
+
+ setattr(self, field, value)
applied.append(field)🤖 Prompt for AI Agents |
||
| applied.append(field) | ||
| logger.info( | ||
| f"[_load_secret] Loaded AWS secret | " | ||
| f"{{'name': '{secret_name}', 'fields': {sorted(applied)}}}" | ||
| ) | ||
|
|
||
| @model_validator(mode="after") | ||
| def _enforce_non_default_secrets(self) -> Self: | ||
| self._check_default_secret("SECRET_KEY", self.SECRET_KEY) | ||
|
|
@@ -201,7 +272,6 @@ def get_settings() -> Settings: | |
| env_files = {"testing": "../.env.test", "development": "../.env"} | ||
| env_file = env_files.get(environment, "../.env") | ||
|
|
||
| # Create Settings instance with the appropriate env file | ||
| return Settings(_env_file=env_file) | ||
|
|
||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,132 @@ | ||
| """Tests for the _apply_aws_secrets validator on Settings.""" | ||
|
|
||
| import json | ||
| from unittest.mock import MagicMock, patch | ||
|
|
||
| import pytest | ||
|
|
||
| from app.core.config import Settings | ||
|
|
||
| _REQUIRED_FIELDS = { | ||
| "PROJECT_NAME": "test", | ||
| "POSTGRES_SERVER": "localhost", | ||
| "POSTGRES_USER": "env-user", | ||
| "POSTGRES_PASSWORD": "env-pw", | ||
| "EMAIL_TEST_USER": "t@example.com", | ||
| "FIRST_SUPERUSER": "s@example.com", | ||
| "FIRST_SUPERUSER_PASSWORD": "superpw", | ||
| } | ||
|
|
||
|
|
||
| @pytest.fixture(autouse=True) | ||
| def _clean_aws_env(monkeypatch): | ||
| """Strip any AWS-related env vars that may leak from .env.test.""" | ||
| for var in ( | ||
| "USE_AWS_SECRETS", | ||
| "AWS_POSTGRES_SECRET_NAME", | ||
| "AWS_SECRETS_REGION", | ||
| "AWS_DEFAULT_REGION", | ||
| ): | ||
| monkeypatch.delenv(var, raising=False) | ||
|
|
||
|
|
||
| def _make_settings(**overrides) -> Settings: | ||
| """Build a Settings instance bypassing the .env file.""" | ||
| return Settings(_env_file=None, **{**_REQUIRED_FIELDS, **overrides}) | ||
|
|
||
|
|
||
| class TestApplyAwsSecrets: | ||
| def test_toggle_off_keeps_env_values(self): | ||
| s = _make_settings(USE_AWS_SECRETS=False) | ||
| assert s.POSTGRES_PASSWORD == "env-pw" | ||
| assert s.POSTGRES_USER == "env-user" | ||
|
|
||
| def test_toggle_on_but_no_secret_names_keeps_env_values(self): | ||
| s = _make_settings(USE_AWS_SECRETS=True) | ||
| assert s.POSTGRES_PASSWORD == "env-pw" | ||
| assert s.POSTGRES_USER == "env-user" | ||
|
|
||
| @patch("boto3.client") | ||
| def test_postgres_secret_overrides_postgres_fields(self, mock_boto): | ||
| mock_client = MagicMock() | ||
| mock_client.get_secret_value.return_value = { | ||
| "SecretString": json.dumps( | ||
| {"username": "u", "password": "p", "host": "h", "port": 5433} | ||
| ) | ||
| } | ||
| mock_boto.return_value = mock_client | ||
|
|
||
| setting = _make_settings( | ||
| USE_AWS_SECRETS=True, | ||
| AWS_POSTGRES_SECRET_NAME="kaapi-db", | ||
| AWS_SECRETS_REGION="ap-south-1", | ||
| ) | ||
|
|
||
| assert setting.POSTGRES_USER == "u" | ||
| assert setting.POSTGRES_PASSWORD == "p" | ||
| assert setting.POSTGRES_SERVER == "h" | ||
| assert setting.POSTGRES_PORT == 5433 | ||
| mock_boto.assert_called_once_with("secretsmanager", region_name="ap-south-1") | ||
| mock_client.get_secret_value.assert_called_once_with(SecretId="kaapi-db") | ||
|
|
||
| @patch("boto3.client") | ||
| def test_missing_keys_fall_back_to_env(self, mock_boto): | ||
| mock_client = MagicMock() | ||
| mock_client.get_secret_value.return_value = { | ||
| "SecretString": json.dumps({"password": "aws-pw"}) | ||
| } | ||
| mock_boto.return_value = mock_client | ||
|
|
||
| setting = _make_settings( | ||
| USE_AWS_SECRETS=True, | ||
| AWS_POSTGRES_SECRET_NAME="kaapi-db", | ||
| ) | ||
|
|
||
| assert setting.POSTGRES_PASSWORD == "aws-pw" | ||
| assert setting.POSTGRES_USER == "env-user" | ||
| assert setting.POSTGRES_SERVER == "localhost" | ||
|
|
||
| @patch("boto3.client") | ||
| def test_non_dict_payload_raises(self, mock_boto): | ||
| mock_client = MagicMock() | ||
| mock_client.get_secret_value.return_value = { | ||
| "SecretString": json.dumps(["not", "a", "dict"]) | ||
| } | ||
| mock_boto.return_value = mock_client | ||
|
|
||
| with pytest.raises(ValueError, match="must be a JSON object"): | ||
| _make_settings( | ||
| USE_AWS_SECRETS=True, | ||
| AWS_POSTGRES_SECRET_NAME="kaapi-db", | ||
| ) | ||
|
|
||
| @patch("boto3.client") | ||
| def test_region_falls_back_to_aws_default_region(self, mock_boto): | ||
| mock_client = MagicMock() | ||
| mock_client.get_secret_value.return_value = { | ||
| "SecretString": json.dumps({"password": "p"}) | ||
| } | ||
| mock_boto.return_value = mock_client | ||
|
|
||
| _make_settings( | ||
| USE_AWS_SECRETS=True, | ||
| AWS_POSTGRES_SECRET_NAME="kaapi-db", | ||
| AWS_DEFAULT_REGION="us-east-1", | ||
| ) | ||
|
|
||
| mock_boto.assert_called_once_with("secretsmanager", region_name="us-east-1") | ||
|
|
||
| @patch("boto3.client") | ||
| def test_no_region_builds_client_without_kwarg(self, mock_boto): | ||
| mock_client = MagicMock() | ||
| mock_client.get_secret_value.return_value = { | ||
| "SecretString": json.dumps({"password": "p"}) | ||
| } | ||
| mock_boto.return_value = mock_client | ||
|
|
||
| _make_settings( | ||
| USE_AWS_SECRETS=True, | ||
| AWS_POSTGRES_SECRET_NAME="kaapi-db", | ||
| ) | ||
|
|
||
| mock_boto.assert_called_once_with("secretsmanager") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Reorder the new keys to satisfy dotenv-linter.
The new block is in the right template file, but the linter expects these keys ordered as
AWS_POSTGRES_SECRET_NAME,AWS_SECRETS_REGION, thenUSE_AWS_SECRETS.🧹 Proposed fix
📝 Committable suggestion
🧰 Tools
🪛 dotenv-linter (4.0.0)
[warning] 102-102: [UnorderedKey] The AWS_SECRETS_REGION key should go before the USE_AWS_SECRETS key
(UnorderedKey)
[warning] 103-103: [UnorderedKey] The AWS_POSTGRES_SECRET_NAME key should go before the AWS_SECRETS_REGION key
(UnorderedKey)
🤖 Prompt for AI Agents