Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,8 @@ SMTP_PASSWORD=
EMAILS_FROM_EMAIL=
EMAILS_FROM_NAME=Kaapi
FRONTEND_HOST=

# AWS Secrets Manager
USE_AWS_SECRETS=false
AWS_SECRETS_REGION=
AWS_POSTGRES_SECRET_NAME=
Comment on lines +100 to +103
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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, then USE_AWS_SECRETS.

🧹 Proposed fix
 # AWS Secrets Manager
-USE_AWS_SECRETS=false
-AWS_SECRETS_REGION=
 AWS_POSTGRES_SECRET_NAME=
+AWS_SECRETS_REGION=
+USE_AWS_SECRETS=false
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# AWS Secrets Manager
USE_AWS_SECRETS=false
AWS_SECRETS_REGION=
AWS_POSTGRES_SECRET_NAME=
# AWS Secrets Manager
AWS_POSTGRES_SECRET_NAME=
AWS_SECRETS_REGION=
USE_AWS_SECRETS=false
🧰 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
Verify each finding against the current code and only fix it if needed.

In @.env.example around lines 100 - 103, The dotenv-linter requires the
AWS-related env keys to be sorted; currently the block shows USE_AWS_SECRETS,
AWS_SECRETS_REGION, AWS_POSTGRES_SECRET_NAME. Reorder the three variables so
they appear as AWS_POSTGRES_SECRET_NAME, AWS_SECRETS_REGION, USE_AWS_SECRETS
(preserving their values/comments) in the .env.example file to satisfy the
linter.

78 changes: 74 additions & 4 deletions backend/app/core/config.py
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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can order this alphabetically

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Expand All @@ -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
Expand Down Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fail fast when required Postgres settings remain empty.

Making POSTGRES_SERVER and POSTGRES_USER optional helps AWS overlays, but staging/production can now instantiate Settings with empty DB fields if the secret name is missing or AWS secrets are disabled. Add a post-overlay validation for required DB settings.

🛡️ 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
Verify each finding against the current code and only fix it if needed.

In `@backend/app/core/config.py` around lines 57 - 61, Add a post-overlay
validation on the Settings model to fail fast when DB fields are empty: in the
Settings class add a pydantic root_validator (or validate method) that checks
POSTGRES_SERVER, POSTGRES_USER, POSTGRES_PASSWORD and POSTGRES_DB are non-empty
after environment/overlay loading and raise a clear ValueError if any required
field is blank; reference the Settings class and the
POSTGRES_SERVER/POSTGRES_USER/POSTGRES_PASSWORD/POSTGRES_DB fields so missing DB
configuration cannot silently instantiate.

KAAPI_GUARDRAILS_AUTH: str = ""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Comment thread
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Pydantic v2 BaseModel validate_assignment default setattr assignment validation model_validator after

💡 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, @field_validator in 'before'/'plain'/'after'/'wrap' modes), but model validators (@model_validator) are generally not fully triggered in the same way. Key behaviors and issues: - Field validators run on the assigned field value during setattr. - @model_validator(mode='after') can cause RecursionError if it assigns to fields (triggers validation again) (sources 13,20). - Workaround: Use self.dict['field'] = value or object.setattr(self, 'field', value) inside model validators to bypass validation. - @model_validator(mode='before') may not apply correctly to single-field assignment data (open bug, source 10). - Value may be set before model 'after' validation, so rejecting in model validator doesn't revert it (source 21). Default is validate_assignment=False. Only affects post-init setattr, not init validation. For complete model-level validation on assignment, use field validators primarily; model validators have limitations with assignment.

Citations:


🏁 Script executed:

cat -n backend/app/core/config.py | head -250 | tail -50

Repository: ProjectTech4DevAI/kaapi-backend

Length of output: 2165


🏁 Script executed:

wc -l backend/app/core/config.py

Repository: ProjectTech4DevAI/kaapi-backend

Length of output: 105


🏁 Script executed:

cat -n backend/app/core/config.py

Repository: ProjectTech4DevAI/kaapi-backend

Length of output: 11070


Add validation and type coercion before assigning secret values to typed fields.

Using setattr without validation bypasses Pydantic's field validators. Since the secret data comes from JSON (line 234), values are strings. The code assigns these directly: if the secret contains "port": "5432", it sets POSTGRES_PORT (typed as int) to the string "5432", violating the type contract. Validate and coerce the value to the target field's type before assignment.

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
Verify each finding against the current code and only fix it if needed.

In `@backend/app/core/config.py` around lines 241 - 243, The loop assigns raw JSON
secret strings directly to typed attributes (field_map, data, setattr),
bypassing Pydantic validation and allowing wrong types (e.g., "5432" to int).
Instead, collect the mapped key→field pairs from data into a temp dict, run it
through Pydantic validation (e.g., use self.__class__.parse_obj or construct a
new model instance) to coerce/validate types, and then assign the validated
attributes back to self (or replace self's state) rather than calling setattr
with the raw values.

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)
Expand All @@ -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)


Expand Down
132 changes: 132 additions & 0 deletions backend/app/tests/core/test_config.py
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")
20 changes: 10 additions & 10 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@ services:
env_file:
- .env
environment:
POSTGRES_USER: ${POSTGRES_USER:?POSTGRES_USER not set}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD not set}
POSTGRES_DB: ${POSTGRES_DB:?POSTGRES_DB not set}
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-kaapi}
PGDATA: /var/lib/postgresql/data/pgdata
volumes:
- kaapi-postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-kaapi}"]
Comment thread
Ayush8923 marked this conversation as resolved.
interval: 10s
timeout: 10s
retries: 5
Expand All @@ -30,8 +30,8 @@ services:
env_file:
- .env
command: >
sh -c "if [ -n \"${REDIS_PASSWORD}\" ]; then
redis-server --requirepass \"${REDIS_PASSWORD}\";
sh -c "if [ -n \"${REDIS_PASSWORD:-}\" ]; then
redis-server --requirepass \"${REDIS_PASSWORD:-}\";
else
redis-server;
fi"
Expand All @@ -40,7 +40,7 @@ services:
ports:
- "6379:6379"
healthcheck:
test: ["CMD-SHELL", "if [ -n \"${REDIS_PASSWORD}\" ]; then redis-cli -a \"${REDIS_PASSWORD}\" ping; else redis-cli ping; fi"]
test: ["CMD-SHELL", "if [ -n \"${REDIS_PASSWORD:-}\" ]; then redis-cli -a \"${REDIS_PASSWORD:-}\" ping; else redis-cli ping; fi"]
interval: 10s
timeout: 10s
retries: 5
Expand All @@ -53,9 +53,9 @@ services:
env_file:
- .env
environment:
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:?RABBITMQ_USER not set}
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:?RABBITMQ_PASSWORD not set}
RABBITMQ_DEFAULT_VHOST: ${RABBITMQ_VHOST:?RABBITMQ_VHOST not set}
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-guest}
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-guest}
RABBITMQ_DEFAULT_VHOST: ${RABBITMQ_VHOST:-/}
volumes:
- kaapi-rabbitmq:/var/lib/rabbitmq
ports:
Expand Down
Loading