From e1a12bcb14ac2034e269e354925225c189d88199 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Fern=C3=A1ndez=20Poyatos?= Date: Fri, 2 Aug 2024 17:29:05 +0200 Subject: [PATCH] feat/PRWLR-3989 Add RLS full implementation (#18) * feat(Backend): PRWLR-3989 add RLS to postgresql db and base models * feat(API): PRWLR-3989 add TenantMiddleware * chore(API, Backend): PRWLR-3989 create new db user without RLS bypass on migrations * chore(Backend): PRWLR-3989 fix RLS bypasser for POST requests * fix(Backend): PRWLR-3989 fix user permissions when migrating new models * chore(Backend): PRWLR-3989 add testing view for RLS manual tests * feat(API): PRWLR-3989 add tenant_id to API logging * chore(API, Backend): PRWLR-3989 add TODOs * test(API): PRWLR-3989 add new middleware unit tests * chore(API): PRWLR-3989 refactor RLS code * fix(tests): PRWLR-3989 fix testing db connector * chore: PRWLR-3989 add references to JIRA tickets * fix: PRWLR-3989 remove bypass logic and fix serializers * fix: PRWLR-3989 improve drop SQL query for RLS models * feat(Backend): PRWLR-3989 add specific permissions on each model * fix(Backend): PRWLR-3989 fix database routing and grant select perms * fix(test): PRWLR-3989 fix routing issues with unit tests * chore: PRWLR-3989 remove references to JIRA tickets * chore(Backend): PRWLR-3989 adjust privileges for user on table tenant * chore: PRWLR-3989 add comments on migrations --- .env.example | 4 +- docker-compose.yml | 6 +- docker-entrypoint.sh | 2 +- src/backend/api/db_router.py | 18 +++ src/backend/api/filters.py | 2 +- src/backend/api/fixtures/dev_tenants.json | 20 +++ src/backend/api/middleware.py | 49 ++++++++ src/backend/api/migrations/0001_initial.py | 37 +++++- .../migrations/0002_test_test_rls_on_test.py | 46 +++++++ src/backend/api/models.py | 30 ++--- src/backend/api/rls.py | 116 ++++++++++++++++++ src/backend/api/serializers.py | 18 ++- src/backend/api/tests/test_database.py | 32 +++++ src/backend/api/tests/test_middleware.py | 46 ++++++- src/backend/api/tests/test_views.py | 2 +- src/backend/api/urls/v1/urls.py | 3 +- src/backend/api/views/v1/views.py | 12 +- src/backend/config/custom_logging.py | 4 + src/backend/config/django/base.py | 8 +- src/backend/config/django/devel.py | 14 ++- src/backend/config/django/production.py | 11 +- src/backend/config/django/testing.py | 2 + 22 files changed, 439 insertions(+), 43 deletions(-) create mode 100644 src/backend/api/db_router.py create mode 100644 src/backend/api/fixtures/dev_tenants.json create mode 100644 src/backend/api/migrations/0002_test_test_rls_on_test.py create mode 100644 src/backend/api/rls.py create mode 100644 src/backend/api/tests/test_database.py diff --git a/.env.example b/.env.example index df80378dc9..811dca5ce1 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,8 @@ DJANGO_STALE_WHILE_REVALIDATE=60 # PostgreSQL settings POSTGRES_HOST=postgres-db POSTGRES_PORT=5432 -POSTGRES_USER=prowler +POSTGRES_ADMIN_USER=prowler +POSTGRES_ADMIN_PASSWORD=S3cret +POSTGRES_USER=prowler_user POSTGRES_PASSWORD=S3cret POSTGRES_DB=prowler_db diff --git a/docker-compose.yml b/docker-compose.yml index 49752e15ee..54ea7e7dc4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,14 +50,14 @@ services: volumes: - ./_data/postgres:/var/lib/postgresql/data environment: - - POSTGRES_USER=${POSTGRES_USER:-prowler} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-S3cret} + - POSTGRES_USER=${POSTGRES_ADMIN_USER:-prowler} + - POSTGRES_PASSWORD=${POSTGRES_ADMIN_PASSWORD:-S3cret} - POSTGRES_DB=${POSTGRES_DB:-prowler_db} env_file: - path: ./.env required: false healthcheck: - test: ["CMD-SHELL", "sh -c 'pg_isready -U prowler -d prowler_db'"] + test: ["CMD-SHELL", "sh -c 'pg_isready -U ${POSTGRES_ADMIN_USER:-prowler} -d ${POSTGRES_DB:-prowler_db}'"] interval: 5s timeout: 5s retries: 5 diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 9089e15946..ad26e778b2 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -3,7 +3,7 @@ apply_migrations() { echo "Applying database migrations..." - poetry run python manage.py migrate + poetry run python manage.py migrate --database admin } start_dev_server() { diff --git a/src/backend/api/db_router.py b/src/backend/api/db_router.py new file mode 100644 index 0000000000..f3a986ac20 --- /dev/null +++ b/src/backend/api/db_router.py @@ -0,0 +1,18 @@ +class MainRouter: + default_db = "default" + admin_db = "admin" + + def db_for_read(self, model, **hints): # noqa: F841 + model_table_name = model._meta.db_table + if model_table_name.startswith("django_"): + return self.admin_db + return None + + def db_for_write(self, model, **hints): # noqa: F841 + model_table_name = model._meta.db_table + if model_table_name.startswith("django_"): + return self.admin_db + return None + + def allow_migrate(self, db, app_label, model_name=None, **hints): # noqa: F841 + return db == self.admin_db diff --git a/src/backend/api/filters.py b/src/backend/api/filters.py index 776187941c..505008febc 100644 --- a/src/backend/api/filters.py +++ b/src/backend/api/filters.py @@ -1,7 +1,7 @@ from django_filters.rest_framework import FilterSet from rest_framework_json_api.django_filters.backends import DjangoFilterBackend -from api.models import Tenant +from api.rls import Tenant class CustomDjangoFilterBackend(DjangoFilterBackend): diff --git a/src/backend/api/fixtures/dev_tenants.json b/src/backend/api/fixtures/dev_tenants.json new file mode 100644 index 0000000000..42bcf9034f --- /dev/null +++ b/src/backend/api/fixtures/dev_tenants.json @@ -0,0 +1,20 @@ +[ + { + "model": "api.tenant", + "pk": "0412980b-06e3-436a-ab98-3c9b1d0333d3", + "fields": { + "inserted_at": "2024-03-21T23:00:00Z", + "updated_at": "2024-03-21T23:00:00Z", + "name": "Tenant2" + } + }, + { + "model": "api.tenant", + "pk": "12646005-9067-4d2a-a098-8bb378604362", + "fields": { + "inserted_at": "2024-03-21T23:00:00Z", + "updated_at": "2024-03-21T23:00:00Z", + "name": "Tenant1" + } + } +] diff --git a/src/backend/api/middleware.py b/src/backend/api/middleware.py index cad6e8c8cb..7554d5823d 100644 --- a/src/backend/api/middleware.py +++ b/src/backend/api/middleware.py @@ -1,9 +1,25 @@ import logging import time +from django.db import connection +from django.http import HttpRequest + from config.custom_logging import BackendLogger +def extract_tenant_id(request: HttpRequest) -> str | None: + """ + Extract the tenant ID from the request headers. + + Args: + request (HttpRequest): The HTTP request object. + + Returns: + str: The tenant ID if present in the headers, otherwise None. + """ + return request.headers.get("X-Tenant-ID") + + class APILoggingMiddleware: """ Middleware for logging API requests. @@ -31,7 +47,40 @@ class APILoggingMiddleware: "query_params": request.GET.dict(), "status_code": response.status_code, "duration": duration, + "tenant_id": extract_tenant_id(request), }, ) return response + + +class TenantMiddleware: + """ + Middleware to handle setting the tenant ID for row-level security (RLS) in the current session. + + This middleware extracts the tenant ID from the request headers and sets it in the session for RLS. + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + tenant_id = extract_tenant_id(request) + + # TODO Define exception handling for POST/PATCH requests without tenant ID + if tenant_id: + self.set_tenant_id_in_session(tenant_id) + + response = self.get_response(request) + return response + + @staticmethod + def set_tenant_id_in_session(tenant_id: str): + """ + Set the tenant ID in the session for RLS using a raw SQL query. + + Args: + tenant_id (str): The tenant ID to set in the session. It is a UUID string. + """ + with connection.cursor() as cursor: + cursor.execute("SET api.tenant_id = %s", [tenant_id]) diff --git a/src/backend/api/migrations/0001_initial.py b/src/backend/api/migrations/0001_initial.py index 6930b9b281..4110af0d26 100644 --- a/src/backend/api/migrations/0001_initial.py +++ b/src/backend/api/migrations/0001_initial.py @@ -1,8 +1,12 @@ -# Generated by Django 5.0.6 on 2024-07-17 07:58 - import uuid + +from django.conf import settings from django.db import migrations, models +DB_NAME = settings.DATABASES["default"]["NAME"] +DB_USER_NAME = settings.DATABASES["default"]["USER"] +DB_USER_PASSWORD = settings.DATABASES["default"]["PASSWORD"] + class Migration(migrations.Migration): initial = True @@ -10,6 +14,27 @@ class Migration(migrations.Migration): dependencies = [] operations = [ + migrations.RunSQL( + f""" + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT + FROM pg_catalog.pg_roles + WHERE rolname = '{DB_USER_NAME}') THEN + CREATE ROLE {DB_USER_NAME} LOGIN PASSWORD '{DB_USER_PASSWORD}'; + END IF; + END + $$; + """ + ), + migrations.RunSQL( + # `runserver` command for dev tools requires read access to migrations + f""" + GRANT CONNECT ON DATABASE "{DB_NAME}" TO {DB_USER_NAME}; + GRANT SELECT ON django_migrations TO {DB_USER_NAME}; + """ + ), migrations.CreateModel( name="Tenant", fields=[ @@ -27,7 +52,13 @@ class Migration(migrations.Migration): ("name", models.CharField(max_length=100)), ], options={ - "abstract": False, + "db_table": "tenant", }, ), + migrations.RunSQL( + # Needed for now since we don't have users yet + f""" + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE tenant TO {DB_USER_NAME}; + """ + ), ] diff --git a/src/backend/api/migrations/0002_test_test_rls_on_test.py b/src/backend/api/migrations/0002_test_test_rls_on_test.py new file mode 100644 index 0000000000..70aa5475cd --- /dev/null +++ b/src/backend/api/migrations/0002_test_test_rls_on_test.py @@ -0,0 +1,46 @@ +# Generated by Django 5.0.6 on 2024-08-01 15:11 + +import api.rls +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="Test", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100)), + ( + "tenant", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="api.tenant" + ), + ), + ], + options={ + "db_table": "test", + }, + ), + migrations.AddConstraint( + model_name="test", + constraint=api.rls.RowLevelSecurityConstraint( + "tenant_id", + name="rls_on_test", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + ), + ] diff --git a/src/backend/api/models.py b/src/backend/api/models.py index 45ec34f08d..ad69cac12f 100644 --- a/src/backend/api/models.py +++ b/src/backend/api/models.py @@ -1,25 +1,17 @@ -import uuid - from django.db import models +from api.rls import RowLevelSecurityProtectedModel, RowLevelSecurityConstraint -class Base(models.Model): - """ - Abstract base model class that provides common fields for all models. - """ - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - - inserted_at = models.DateTimeField(auto_now_add=True, editable=False) - updated_at = models.DateTimeField(auto_now=True, editable=False) +class Test(RowLevelSecurityProtectedModel): + name = models.CharField(max_length=100) class Meta: - abstract = True - - -class Tenant(Base): - """ - The Tenant is the basic grouping in the system. It is used to separate data between customers. - """ - - name = models.CharField(max_length=100) + db_table = "test" + constraints = [ + RowLevelSecurityConstraint( + field="tenant_id", + name="rls_on_%(class)s", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ) + ] diff --git a/src/backend/api/rls.py b/src/backend/api/rls.py new file mode 100644 index 0000000000..2b33f46799 --- /dev/null +++ b/src/backend/api/rls.py @@ -0,0 +1,116 @@ +from typing import Any +from uuid import uuid4 + +from django.conf import settings +from django.db import models +from django.db.backends.ddl_references import Statement, Table + + +class Tenant(models.Model): + """ + The Tenant is the basic grouping in the system. It is used to separate data between customers. + """ + + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + + inserted_at = models.DateTimeField(auto_now_add=True, editable=False) + updated_at = models.DateTimeField(auto_now=True, editable=False) + name = models.CharField(max_length=100) + + class Meta: + db_table = "tenant" + + +# TODO Add abstract class for non-RLS models +class RowLevelSecurityConstraint(models.BaseConstraint): + TENANT_SETTING = "api.tenant_id" + + rls_sql_query = """ + ALTER TABLE %(table_name)s ENABLE ROW LEVEL SECURITY; + ALTER TABLE %(table_name)s FORCE ROW LEVEL SECURITY; + """ + + policy_sql_query = """ + CREATE POLICY %(db_user)s_%(table_name)s_{statement} + ON %(table_name)s + FOR {statement} + TO %(db_user)s + {clause} ( + CASE + WHEN current_setting('%(tenant_setting)s', True) IS NULL THEN FALSE + ELSE %(field_column)s = current_setting('%(tenant_setting)s')::uuid + END + ); + """ + + grant_sql_query = """ + GRANT {statement} ON %(table_name)s TO %(db_user)s; + """ + + drop_sql_query = """ + ALTER TABLE %(table_name)s NO FORCE ROW LEVEL SECURITY; + ALTER TABLE %(table_name)s DISABLE ROW LEVEL SECURITY; + REVOKE ALL ON TABLE %(table_name) TO %(db_user)s; + """ + + drop_policy_sql_query = """ + DROP POLICY IF EXISTS %(db_user)s_%(table_name)s_{statement} on %(table_name)s; + """ + + def __init__(self, field: str, name: str, statements: list | None = None) -> None: + super().__init__(name=name) + self.target_field: str = field + self.statements = statements or ["SELECT"] + + def create_sql(self, model: Any, schema_editor: Any) -> Any: + field_column = schema_editor.quote_name(self.target_field) + + policy_queries = "" + grant_queries = "" + for statement in self.statements: + clause = f"{'WITH CHECK' if statement == 'INSERT' else 'USING'}" + policy_queries = f"{policy_queries}{self.policy_sql_query.format(statement=statement, clause=clause)}" + grant_queries = ( + f"{grant_queries}{self.grant_sql_query.format(statement=statement)}" + ) + + full_create_sql_query = ( + f"{self.rls_sql_query}" f"{policy_queries}" f"{grant_queries}" + ) + + return Statement( + full_create_sql_query, + table_name=model._meta.db_table, + field_column=field_column, + db_user=settings.DATABASES["default"]["USER"], + tenant_setting=self.TENANT_SETTING, + ) + + def remove_sql(self, model: Any, schema_editor: Any) -> Any: + field_column = schema_editor.quote_name(self.target_field) + full_drop_sql_query = ( + f"{self.drop_sql_query}" + f"{''.join([self.drop_policy_sql_query.format(statement) for statement in self.statements])}" + ) + return Statement( + full_drop_sql_query, + table_name=Table(model._meta.db_table, schema_editor.quote_name), + field_column=field_column, + db_user=settings.DATABASES["default"]["USER"], + ) + + def __eq__(self, other: object) -> bool: + if isinstance(other, RowLevelSecurityConstraint): + return self.name == other.name and self.target_field == other.target_field + return super().__eq__(other) + + def deconstruct(self) -> tuple[str, tuple, dict]: + path, _, kwargs = super().deconstruct() + return (path, (self.target_field,), kwargs) + + +class RowLevelSecurityProtectedModel(models.Model): + tenant = models.ForeignKey("Tenant", on_delete=models.CASCADE) + + class Meta: + abstract = True diff --git a/src/backend/api/serializers.py b/src/backend/api/serializers.py index df5d6a5a0f..46bcffb9c3 100644 --- a/src/backend/api/serializers.py +++ b/src/backend/api/serializers.py @@ -1,6 +1,7 @@ from rest_framework_json_api import serializers -from api.models import Tenant +from api.models import Test +from api.rls import Tenant class BaseSerializerV1(serializers.ModelSerializer): @@ -16,3 +17,18 @@ class TenantSerializer(BaseSerializerV1): class Meta: model = Tenant fields = "__all__" + + +class TestSerializer(BaseSerializerV1): + tenant = serializers.PrimaryKeyRelatedField( + queryset=Tenant.objects.all(), write_only=True + ) + + class Meta: + model = Test + fields = "__all__" + + def to_representation(self, instance): + representation = super().to_representation(instance) + representation.pop("tenant", None) + return representation diff --git a/src/backend/api/tests/test_database.py b/src/backend/api/tests/test_database.py new file mode 100644 index 0000000000..4c42db40f1 --- /dev/null +++ b/src/backend/api/tests/test_database.py @@ -0,0 +1,32 @@ +import pytest +from django.conf import settings +from django.db.migrations.recorder import MigrationRecorder +from django.db.utils import ConnectionRouter + +from api.db_router import MainRouter +from api.models import Test +from api.rls import Tenant +from config.django.base import DATABASE_ROUTERS as PROD_DATABASE_ROUTERS + + +class TestMainDatabaseRouter: + @pytest.fixture(scope="module") + def router(self): + testing_routers = settings.DATABASE_ROUTERS.copy() + settings.DATABASE_ROUTERS = PROD_DATABASE_ROUTERS + yield ConnectionRouter() + settings.DATABASE_ROUTERS = testing_routers + + @pytest.mark.parametrize("api_model", [Tenant, Test]) + def test_router_api_models(self, api_model, router): + assert router.db_for_read(api_model) == MainRouter.default_db + assert router.db_for_write(api_model) == MainRouter.default_db + + assert router.allow_migrate_model(MainRouter.admin_db, api_model) + assert not router.allow_migrate_model(MainRouter.default_db, api_model) + + def test_router_django_models(self, router): + assert router.db_for_read(MigrationRecorder.Migration) == MainRouter.admin_db + assert ( + not router.db_for_read(MigrationRecorder.Migration) == MainRouter.default_db + ) diff --git a/src/backend/api/tests/test_middleware.py b/src/backend/api/tests/test_middleware.py index 4b9f740ec8..88c94beb00 100644 --- a/src/backend/api/tests/test_middleware.py +++ b/src/backend/api/tests/test_middleware.py @@ -1,15 +1,24 @@ from unittest.mock import patch, Mock, ANY +import pytest +from django.http import HttpResponse +from api.middleware import extract_tenant_id, TenantMiddleware + + +@pytest.mark.django_db @patch("logging.getLogger") def test_api_logger_middleware(mock_get_logger, client): mock_logger = Mock() mock_get_logger.side_effect = lambda name: mock_logger if name == "api" else Mock() request_method = "GET" request_path = "/this_path_does_not_exist" + tenant_id = "12646005-9067-4d2a-a098-8bb378604362" api_logger = "api" - response = getattr(client, request_method.lower())(request_path) + response = getattr(client, request_method.lower())( + request_path, headers={"X-Tenant-ID": tenant_id} + ) mock_get_logger.assert_any_call(api_logger) mock_logger.info.assert_called_once_with( @@ -20,7 +29,42 @@ def test_api_logger_middleware(mock_get_logger, client): "query_params": {}, "status_code": response.status_code, "duration": ANY, + "tenant_id": tenant_id, }, ) assert isinstance(mock_logger.info.call_args[1]["extra"]["duration"], float) + + +@patch("api.middleware.TenantMiddleware.set_tenant_id_in_session") +def test_tenant_middleware(mock_set_tenant, client): + tenant_id = "12646005-9067-4d2a-a098-8bb378604362" + tenant_header = {"X-Tenant-ID": tenant_id} + + response = client.get("/testing", headers=tenant_header) + + mock_set_tenant.assert_called_once_with(tenant_id) + assert isinstance(response, HttpResponse) + + +@patch("api.middleware.connection.cursor") +def test_tenant_middleware_set_tenant_id_in_session(cursor_mock): + cursor_mock.return_value.__enter__.return_value = cursor_mock + cursor_mock.execute.return_value = None + + tenant_id_postgres_variable = "api.tenant_id" + tenant_id = "12646005-9067-4d2a-a098-8bb378604362" + tenant_middleware = TenantMiddleware(Mock()) + + tenant_middleware.set_tenant_id_in_session(tenant_id) + + cursor_mock.execute.assert_called_once_with( + f"SET {tenant_id_postgres_variable} = %s", [tenant_id] + ) + + +def test_extract_tenant_id(): + mock_request = Mock() + extract_tenant_id(mock_request) + + mock_request.headers.get.assert_called_once_with("X-Tenant-ID") diff --git a/src/backend/api/tests/test_views.py b/src/backend/api/tests/test_views.py index 815200657d..2bd8975cdb 100644 --- a/src/backend/api/tests/test_views.py +++ b/src/backend/api/tests/test_views.py @@ -2,7 +2,7 @@ import pytest from django.urls import reverse from rest_framework import status -from api.models import Tenant +from api.rls import Tenant API_JSON_CONTENT_TYPE = "application/vnd.api+json" diff --git a/src/backend/api/urls/v1/urls.py b/src/backend/api/urls/v1/urls.py index 5f7e599c99..2c66e2575e 100644 --- a/src/backend/api/urls/v1/urls.py +++ b/src/backend/api/urls/v1/urls.py @@ -2,7 +2,7 @@ from django.urls import path, include from drf_spectacular.views import SpectacularRedocView from rest_framework import routers -from api.views.v1.views import SchemaView, TenantViewSet, ProviderViewSet +from api.views.v1.views import SchemaView, TenantViewSet, ProviderViewSet, TestView router = routers.DefaultRouter(trailing_slash=False) @@ -13,4 +13,5 @@ urlpatterns = [ path("", include(router.urls)), path("schema", SchemaView.as_view(), name="schema"), path("docs", SpectacularRedocView.as_view(url_name="schema"), name="docs"), + path("test", TestView.as_view(), name="test"), ] diff --git a/src/backend/api/views/v1/views.py b/src/backend/api/views/v1/views.py index 6d9a8898c8..368bd25716 100644 --- a/src/backend/api/views/v1/views.py +++ b/src/backend/api/views/v1/views.py @@ -7,10 +7,11 @@ from drf_spectacular.views import SpectacularAPIView from rest_framework import status, viewsets from rest_framework.decorators import action from rest_framework_json_api.views import Response - from api.filters import TenantFilter -from api.models import Tenant -from api.serializers import TenantSerializer +from rest_framework.generics import ListCreateAPIView +from api.models import Test +from api.rls import Tenant +from api.serializers import TenantSerializer, TestSerializer from api.views.base_views import BaseViewSet CACHE_DECORATOR = cache_control( @@ -317,3 +318,8 @@ class ProviderViewSet(viewsets.ViewSet): } return Response(connection_data_mock, status=status.HTTP_200_OK) + + +class TestView(ListCreateAPIView): + serializer_class = TestSerializer + queryset = Test.objects.all() diff --git a/src/backend/config/custom_logging.py b/src/backend/config/custom_logging.py index 6e869db026..864c3b63ed 100644 --- a/src/backend/config/custom_logging.py +++ b/src/backend/config/custom_logging.py @@ -55,6 +55,8 @@ class NDJSONFormatter(logging.Formatter): log_record["duration"] = record.duration if hasattr(record, "status_code"): log_record["status_code"] = record.status_code + if hasattr(record, "tenant_id"): + log_record["tenant_id"] = record.tenant_id if record.exc_info: log_record["exc_info"] = self.formatException(record.exc_info) @@ -85,6 +87,8 @@ class HumanReadableFormatter(logging.Formatter): ] # Add REST API extra fields + if hasattr(record, "tenant_id"): + log_components.append(f"({record.tenant_id})") if hasattr(record, "method"): log_components.append(f'"{record.method} {record.path}"') if hasattr(record, "query_params"): diff --git a/src/backend/config/django/base.py b/src/backend/config/django/base.py index 86e3868c12..5d1fffc769 100644 --- a/src/backend/config/django/base.py +++ b/src/backend/config/django/base.py @@ -1,12 +1,10 @@ from config.custom_logging import LOGGING # noqa from config.env import BASE_DIR, env # noqa - SECRET_KEY = env("SECRET_KEY", default="secret") DEBUG = env.bool("DJANGO_DEBUG", default=False) ALLOWED_HOSTS = ["localhost", "127.0.0.1"] - # Application definition INSTALLED_APPS = [ @@ -34,6 +32,7 @@ MIDDLEWARE = [ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", + "api.middleware.TenantMiddleware", "api.middleware.APILoggingMiddleware", ] @@ -99,6 +98,9 @@ DJANGO_GUID = { "UUID_LENGTH": 32, } +DATABASE_ROUTERS = ["api.db_router.MainRouter"] + + # Password validation # https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators @@ -117,7 +119,6 @@ AUTH_PASSWORD_VALIDATORS = [ }, ] - # Internationalization # https://docs.djangoproject.com/en/5.0/topics/i18n/ @@ -132,7 +133,6 @@ USE_I18N = True USE_TZ = True - # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/5.0/howto/static-files/ diff --git a/src/backend/config/django/devel.py b/src/backend/config/django/devel.py index c53bbeb420..99c34565f5 100644 --- a/src/backend/config/django/devel.py +++ b/src/backend/config/django/devel.py @@ -10,11 +10,19 @@ DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql", "NAME": env("POSTGRES_DB", default="prowler_db"), - "USER": env("POSTGRES_USER", default="prowler"), - "PASSWORD": env("POSTGRES_PASSWORD", default="S3cret"), + "USER": env("POSTGRES_USER", default="prowler_user"), + "PASSWORD": env("POSTGRES_PASSWORD", default="prowler"), "HOST": env("POSTGRES_HOST", default="postgres-db"), "PORT": env("POSTGRES_PORT", default="5432"), - } + }, + "admin": { + "ENGINE": "django.db.backends.postgresql", + "NAME": env("POSTGRES_DB", default="prowler_db"), + "USER": env("POSTGRES_ADMIN_USER", default="prowler"), + "PASSWORD": env("POSTGRES_ADMIN_PASSWORD", default="S3cret"), + "HOST": env("POSTGRES_HOST", default="postgres-db"), + "PORT": env("POSTGRES_PORT", default="5432"), + }, } diff --git a/src/backend/config/django/production.py b/src/backend/config/django/production.py index bf99d9ad19..0d505ea587 100644 --- a/src/backend/config/django/production.py +++ b/src/backend/config/django/production.py @@ -6,6 +6,7 @@ DEBUG = env.bool("DJANGO_DEBUG", default=False) ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=["localhost", "127.0.0.1"]) # Database +# TODO Use Django database routers https://docs.djangoproject.com/en/5.0/topics/db/multi-db/#automatic-database-routing DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql", @@ -14,5 +15,13 @@ DATABASES = { "PASSWORD": env("POSTGRES_PASSWORD"), "HOST": env("POSTGRES_HOST"), "PORT": env("POSTGRES_PORT"), - } + }, + "admin": { + "ENGINE": "django.db.backends.postgresql", + "NAME": env("POSTGRES_DB"), + "USER": env("POSTGRES_ADMIN_USER"), + "PASSWORD": env("POSTGRES_ADMIN_PASSWORD"), + "HOST": env("POSTGRES_HOST"), + "PORT": env("POSTGRES_PORT"), + }, } diff --git a/src/backend/config/django/testing.py b/src/backend/config/django/testing.py index b0164b6c9f..79a8a48cca 100644 --- a/src/backend/config/django/testing.py +++ b/src/backend/config/django/testing.py @@ -15,3 +15,5 @@ DATABASES = { "PORT": env("POSTGRES_PORT", default="5432"), } } + +DATABASE_ROUTERS = []