diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index e9dac993ce..af4b9e69bf 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to the **Prowler API** are documented in this file. - Automatic recovery of allowlisted idempotent background tasks whose worker died during a deploy or crash: stuck scan and summary tasks are detected and re-run instead of staying pending forever, with a `reconcile_orphan_tasks` management command for on-demand recovery [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416) - Jira integration no longer creates duplicate issues on a retried send; findings already ticketed are skipped [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416) - DORA compliance framework support [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131) +- Label Postgres connections with `application_name=":"` (component injected per process via `DJANGO_APP_COMPONENT`) so connections are attributable by component in `pg_stat_activity` [(#11494)](https://github.com/prowler-cloud/prowler/pull/11494) ### 🔄 Changed diff --git a/api/docker-entrypoint.sh b/api/docker-entrypoint.sh index e6313f459a..9b2964b479 100755 --- a/api/docker-entrypoint.sh +++ b/api/docker-entrypoint.sh @@ -68,6 +68,15 @@ manage_db_partitions() { fi } +# Identify this process to Postgres (application_name=:) so +# connections are attributable by component in pg_stat_activity. Web tiers +# report "api"; everything else uses the launch subcommand. +case "$1" in + prod|dev) DJANGO_APP_COMPONENT="api" ;; + *) DJANGO_APP_COMPONENT="$1" ;; +esac +export DJANGO_APP_COMPONENT + case "$1" in dev) apply_migrations diff --git a/api/src/backend/api/tests/test_db_connection_labels.py b/api/src/backend/api/tests/test_db_connection_labels.py new file mode 100644 index 0000000000..a39e7d9051 --- /dev/null +++ b/api/src/backend/api/tests/test_db_connection_labels.py @@ -0,0 +1,55 @@ +from config.django.base import label_postgres_connections + + +class TestLabelPostgresConnections: + def test_labels_postgres_and_skips_neo4j(self, monkeypatch): + monkeypatch.setenv("DJANGO_APP_COMPONENT", "scan") + databases = { + "default": {"ENGINE": "psqlextra.backend"}, + "neo4j": {"HOST": "neo4j", "PORT": "7687"}, + } + + label_postgres_connections(databases) + + assert databases["default"]["OPTIONS"]["application_name"] == "scan:default" + assert "OPTIONS" not in databases["neo4j"] + + def test_labels_plain_postgresql_backend(self, monkeypatch): + monkeypatch.setenv("DJANGO_APP_COMPONENT", "api") + databases = {"saas": {"ENGINE": "django.db.backends.postgresql"}} + + label_postgres_connections(databases) + + assert databases["saas"]["OPTIONS"]["application_name"] == "api:saas" + + def test_defaults_component_to_api_when_unset(self, monkeypatch): + monkeypatch.delenv("DJANGO_APP_COMPONENT", raising=False) + databases = {"default": {"ENGINE": "psqlextra.backend"}} + + label_postgres_connections(databases) + + assert databases["default"]["OPTIONS"]["application_name"] == "api:default" + + def test_preserves_existing_options(self, monkeypatch): + monkeypatch.setenv("DJANGO_APP_COMPONENT", "worker") + databases = { + "replica": { + "ENGINE": "psqlextra.backend", + "OPTIONS": {"sslmode": "require"}, + } + } + + label_postgres_connections(databases) + + assert databases["replica"]["OPTIONS"] == { + "sslmode": "require", + "application_name": "worker:replica", + } + + def test_truncates_application_name_to_63_bytes(self, monkeypatch): + monkeypatch.setenv("DJANGO_APP_COMPONENT", "c" * 80) + databases = {"default": {"ENGINE": "psqlextra.backend"}} + + label_postgres_connections(databases) + + assert len(databases["default"]["OPTIONS"]["application_name"]) == 63 diff --git a/api/src/backend/config/django/base.py b/api/src/backend/config/django/base.py index 317fe4fbfb..402b71eb51 100644 --- a/api/src/backend/config/django/base.py +++ b/api/src/backend/config/django/base.py @@ -306,3 +306,20 @@ SESSION_COOKIE_SECURE = True ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES = env.int( "ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES", 2880 ) # 48h + + +def label_postgres_connections(databases): + """Tag each Postgres connection with ``application_name=":"`` + so connections are attributable by component in ``pg_stat_activity`` (and any + tooling that surfaces ``application_name``). The component (api / worker / + scan / ...) is injected per process by the container entrypoint via + ``DJANGO_APP_COMPONENT``; the alias distinguishes which pool inside the + process owns the connection. The neo4j entry is skipped (not a Postgres + backend). Postgres truncates ``application_name`` at 63 bytes. + """ + component = env.str("DJANGO_APP_COMPONENT", default="api") + for alias, config in databases.items(): + engine = config.get("ENGINE", "") + if engine.startswith("psqlextra") or "postgresql" in engine: + name = f"{component}:{alias}"[:63] + config.setdefault("OPTIONS", {})["application_name"] = name diff --git a/api/src/backend/config/django/devel.py b/api/src/backend/config/django/devel.py index 9c83557b77..6921790ca3 100644 --- a/api/src/backend/config/django/devel.py +++ b/api/src/backend/config/django/devel.py @@ -54,6 +54,8 @@ DATABASES = { DATABASES["default"] = DATABASES["prowler_user"] +label_postgres_connections(DATABASES) # noqa: F405 + REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"] = tuple( # noqa: F405 render_class for render_class in REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"] # noqa: F405 diff --git a/api/src/backend/config/django/production.py b/api/src/backend/config/django/production.py index 91bd50d0d1..cb651f6e76 100644 --- a/api/src/backend/config/django/production.py +++ b/api/src/backend/config/django/production.py @@ -58,3 +58,5 @@ DATABASES = { } DATABASES["default"] = DATABASES["prowler_user"] + +label_postgres_connections(DATABASES) # noqa: F405