Skip to content
Draft
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
5c91188
feat: add EncryptedJSONField backed by a rotatable Fernet key ring
Zaimwa9 Jul 14, 2026
770a97e
feat: add encrypted credentials column to warehouse connections
Zaimwa9 Jul 14, 2026
73640e9
feat: accept ClickHouse warehouse connection config and credentials
Zaimwa9 Jul 14, 2026
61a2a3a
feat: verify ClickHouse warehouse connections against the customer in…
Zaimwa9 Jul 14, 2026
9a05fe6
feat: wire ClickHouse verification into warehouse connection endpoints
Zaimwa9 Jul 14, 2026
6695381
refactor: extract warehouse validation into a dedicated module
Zaimwa9 Jul 15, 2026
98c0c76
test: neutral ClickHouse fixture data and bare Given/When/Then markers
Zaimwa9 Jul 15, 2026
81cf471
refactor: encrypt warehouse credentials with a SECRET_KEY-derived Fer…
Zaimwa9 Jul 15, 2026
f91b461
refactor: drop the ephemeral SECRET_KEY guard for warehouse credentials
Zaimwa9 Jul 16, 2026
b8c97cb
fix: reject internal network addresses for ClickHouse warehouse hosts
Zaimwa9 Jul 16, 2026
808513a
feat: throttle the warehouse test-connection endpoint
Zaimwa9 Jul 16, 2026
50891b5
feat: record why warehouse connection verification failed
Zaimwa9 Jul 16, 2026
171c14c
chore: update OpenAPI schema with warehouse connection fields
Zaimwa9 Jul 16, 2026
f7a302b
Merge remote-tracking branch 'origin/main' into feat/clickhouse-byo-w…
Zaimwa9 Jul 16, 2026
3589d6c
refactor: move the internal-address check from webhooks to core
Zaimwa9 Jul 16, 2026
77c343a
test: consolidate warehouse connection tests with parametrisation
Zaimwa9 Jul 16, 2026
87861f0
fix: harden warehouse connection validation, throttling, and verifica…
Zaimwa9 Jul 16, 2026
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
1 change: 1 addition & 0 deletions api/app/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,7 @@
"invite": "10/min",
"user": USER_THROTTLE_RATE,
"influx_query": "5/min",
"warehouse_connection_test": "10/min",
},
"DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"],
"DEFAULT_RENDERER_CLASSES": [
Expand Down
1 change: 1 addition & 0 deletions api/app/settings/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"user": "100000/day",
"master_api_key": "100000/day",
"influx_query": "50/min",
"warehouse_connection_test": "100/min",
}

AWS_SSE_LOGS_BUCKET_NAME = "test_bucket"
Expand Down
45 changes: 45 additions & 0 deletions api/core/fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import base64
import hashlib
import json
from typing import Any

import structlog
from cryptography.fernet import Fernet, InvalidToken
from django.conf import settings
from django.db import models

logger = structlog.get_logger("core")


def _get_fernet() -> Fernet:
digest = hashlib.sha256(settings.SECRET_KEY.encode()).digest()
return Fernet(base64.urlsafe_b64encode(digest))
Comment on lines +14 to +16

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## api/core/fields.py\n'
cat -n api/core/fields.py | sed -n '1,120p'

printf '\n## Search for _get_fernet and related encryption settings/tests\n'
rg -n "_get_fernet|Fernet|MultiFernet|SECRET_KEY|FIELD_ENCRYPTION|decrypt|from_db_value|Stored connection details are incomplete" api -S

printf '\n## api/core/services.py (relevant slices)\n'
if [ -f api/core/services.py ]; then
  cat -n api/core/services.py | sed -n '1,240p'
fi

printf '\n## tests mentioning SECRET_KEY rotation or from_db_value\n'
rg -n "secret_key changed|SECRET_KEY changed|from_db_value__secret_key_changed|returns none and logs|incomplete" tests api -S

Repository: Flagsmith/flagsmith

Length of output: 7390


🏁 Script executed:

#!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
p = Path('api/core/fields.py')
print(p.exists(), p)
if p.exists():
    for i, line in enumerate(p.read_text().splitlines(), 1):
        if 1 <= i <= 80:
            print(f"{i:4d}: {line}")
PY

Repository: Flagsmith/flagsmith

Length of output: 1754


Use a dedicated key for encrypted fields. Deriving the Fernet key from SECRET_KEY couples credential encryption to Django’s global signing secret, and rotating SECRET_KEY will make stored values undecryptable while from_db_value returns None, which downstream surfaces as “Stored connection details are incomplete.” Add a separate encryption setting and a rotation path instead of reusing SECRET_KEY.



class EncryptedJSONField(models.TextField[Any, Any]):
def get_prep_value(self, value: Any) -> str | None:
if value is None:
return None
return _get_fernet().encrypt(json.dumps(value).encode()).decode()

def from_db_value(
self,
value: str | None,
expression: object,
connection: object,
) -> Any:
if value is None:
return None
try:
plaintext = _get_fernet().decrypt(value.encode())
except InvalidToken:
logger.warning("encrypted_field.decrypt_failed", exc_info=True)
return None
return json.loads(plaintext)

def get_lookup(self, lookup_name: str) -> Any:
if lookup_name != "isnull":
raise NotImplementedError(
"EncryptedJSONField only supports isnull lookups."
)
return super().get_lookup(lookup_name)
10 changes: 10 additions & 0 deletions api/experimentation/metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import prometheus_client

flagsmith_experimentation_warehouse_connection_verifications_total = (
prometheus_client.Counter(
"flagsmith_experimentation_warehouse_connection_verifications_total",
"Outcomes of connection verification attempts against customers' own "
"data warehouses. `result` label is either `success` or `failure`.",
["result"],
)
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from django.db import migrations

import core.fields


class Migration(migrations.Migration):
dependencies = [
("experimentation", "0009_add_rollout_segment"),
]

operations = [
migrations.AddField(
model_name="warehouseconnection",
name="credentials",
field=core.fields.EncryptedJSONField(blank=True, null=True),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 5.2.16 on 2026-07-16 09:01

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("experimentation", "0010_warehouse_connection_credentials"),
]

operations = [
migrations.AddField(
model_name="warehouseconnection",
name="status_detail",
field=models.CharField(blank=True, max_length=255, null=True),
),
]
3 changes: 3 additions & 0 deletions api/experimentation/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
hook,
)

from core.fields import EncryptedJSONField
from core.models import SoftDeleteExportableModel
from environments.models import Environment
from experimentation.dataclasses import (
Expand Down Expand Up @@ -54,10 +55,12 @@ class WarehouseConnection(LifecycleModelMixin, SoftDeleteExportableModel): # ty
choices=WarehouseConnectionStatus.choices,
default=WarehouseConnectionStatus.CREATED,
)
status_detail = models.CharField(max_length=255, null=True, blank=True)
name = models.CharField(max_length=255)
config: models.JSONField[dict[str, object] | None, dict[str, object] | None] = (
models.JSONField(null=True, blank=True)
)
credentials = EncryptedJSONField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)

# Populated at serialization time for flagsmith connections from ClickHouse;
Expand Down
34 changes: 14 additions & 20 deletions api/experimentation/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@
apply_experiment_rollout,
get_experiment_rollout,
)
from experimentation.types import (
SNOWFLAKE_DEFAULTS,
MetricExperimentResult,
SnowflakeConfig,
from experimentation.types import MetricExperimentResult
from experimentation.warehouse_validation import (
CONFIG_VALIDATORS,
validate_credentials,
)
from features.feature_states.serializers import (
FeatureValueSerializer,
Expand All @@ -41,6 +41,9 @@
class WarehouseConnectionSerializer(serializers.ModelSerializer): # type: ignore[type-arg]
name = serializers.CharField(max_length=255, required=False)
config = serializers.JSONField(default=None, required=False, allow_null=True)
credentials = serializers.JSONField(
default=None, required=False, allow_null=True, write_only=True
)
total_events_received = serializers.SerializerMethodField()
unique_events_count = serializers.SerializerMethodField()

Expand All @@ -50,27 +53,31 @@ class Meta:
"id",
"warehouse_type",
"status",
"status_detail",
"name",
"config",
"credentials",
"created_at",
"total_events_received",
"unique_events_count",
)
read_only_fields = ("id", "status", "created_at")
read_only_fields = ("id", "status", "status_detail", "created_at")

def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
warehouse_type: str = attrs.get(
"warehouse_type",
getattr(self.instance, "warehouse_type", ""),
)

validate_credentials(attrs, warehouse_type, self.instance) # type: ignore[arg-type]

if "config" not in attrs and self.instance is not None:
return attrs

config: dict[str, Any] | None = attrs.get("config")

if warehouse_type == WarehouseType.SNOWFLAKE:
attrs["config"] = self._validate_snowflake_config(config or {})
if config_validator := CONFIG_VALIDATORS.get(warehouse_type):
attrs["config"] = config_validator(config or {})
elif warehouse_type == WarehouseType.FLAGSMITH:
if config:
raise serializers.ValidationError(
Expand Down Expand Up @@ -105,19 +112,6 @@ def _generate_name(warehouse_type: str, environment: Environment) -> str:
label = WarehouseType(warehouse_type).label
return f"{label} Warehouse - {environment.name}"

@staticmethod
def _validate_snowflake_config(config: dict[str, Any]) -> SnowflakeConfig:
account_identifier = config.get("account_identifier", "")
if not account_identifier:
raise serializers.ValidationError(
{"config": {"account_identifier": "This field is required."}}
)
merged: SnowflakeConfig = {
**SNOWFLAKE_DEFAULTS,
**config, # type: ignore[typeddict-item]
}
return merged


class MetricSerializer(serializers.ModelSerializer): # type: ignore[type-arg]
experiments = serializers.SerializerMethodField()
Expand Down
62 changes: 62 additions & 0 deletions api/experimentation/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import structlog
from clickhouse_driver import Client
from clickhouse_driver import errors as clickhouse_errors
from clickhouse_driver.util.helpers import parse_url
from django.conf import settings
from django.db import transaction
Expand Down Expand Up @@ -40,6 +41,9 @@
RolloutSpec,
WarehouseEventStats,
)
from experimentation.metrics import (
flagsmith_experimentation_warehouse_connection_verifications_total,
)
from experimentation.models import (
VALID_STATUS_TRANSITIONS,
ExperimentStatus,
Expand All @@ -55,6 +59,7 @@
compare_to_control,
srm_p_value,
)
from experimentation.types import ClickHouseConfig, ClickHouseCredentials
from features.models import FeatureState
from features.value_types import BOOLEAN, INTEGER, STRING
from features.versioning.dataclasses import FlagChangeSet
Expand Down Expand Up @@ -727,6 +732,63 @@ def mark_warehouse_pending_connection(
return connection


def _describe_verification_error(error: Exception) -> str:
if isinstance(error, clickhouse_errors.ServerException):
if error.code == 516:
return "Authentication failed."
if error.code == 81:
return "Database does not exist."
return "The ClickHouse server rejected the request."
if isinstance(error, (clickhouse_errors.SocketTimeoutError, TimeoutError)):
return "The connection timed out."
if isinstance(error, clickhouse_errors.NetworkError):
return "Could not connect to the host."
if isinstance(error, KeyError):
return "Stored connection details are incomplete."
return "Verification failed."


def verify_clickhouse_connection(connection: WarehouseConnection) -> None:
"""Run SELECT 1 against the customer's ClickHouse and set the status to
connected or errored; never raises."""
log = logger.bind(environment__id=connection.environment_id)
try:
log = log.bind(organisation__id=connection.environment.project.organisation_id)
config = typing.cast(ClickHouseConfig, connection.config or {})
credentials = typing.cast(ClickHouseCredentials, connection.credentials or {})
client = Client(
config["host"],
Comment on lines +827 to +832

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Close the DNS-rebinding gap before opening the socket.

is_internal_address(config["host"]) and Client(config["host"], ...) resolve the hostname independently. An attacker-controlled hostname can return a public address for the check and an internal address for the client connection. Pin the validated address or enforce network-level egress denial for private/link-local destinations.

port=config["port"],
user=config["username"],
password=credentials["password"],
database=config["database"],
secure=config["secure"],
connect_timeout=CLICKHOUSE_CONNECT_TIMEOUT_SECONDS,
send_receive_timeout=CLICKHOUSE_QUERY_TIMEOUT_SECONDS,
)
try:
client.execute("SELECT 1")
finally:
client.disconnect()
except Exception as error:
connection.status = WarehouseConnectionStatus.ERRORED
connection.status_detail = _describe_verification_error(error)
connection.save(update_fields=["status", "status_detail"])
flagsmith_experimentation_warehouse_connection_verifications_total.labels(
result="failure"
).inc()
log.warning("connection.verification_failed", exc_info=True)
return

connection.status = WarehouseConnectionStatus.CONNECTED
connection.status_detail = None
connection.save(update_fields=["status", "status_detail"])
flagsmith_experimentation_warehouse_connection_verifications_total.labels(
result="success"
).inc()
log.info("connection.verification_succeeded")
Comment on lines +819 to +861

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the verifier and its call sites.
printf '\n== services.py around verify_clickhouse_connection ==\n'
sed -n '780,900p' api/experimentation/services.py

printf '\n== views.py references ==\n'
rg -n "verify_clickhouse_connection|write_environment_ingestion_keys\.delay|AFTER_CREATE|perform_create|perform_update|test connection|connection.verification" api/experimentation -n

printf '\n== views.py relevant slices ==\n'
for f in api/experimentation/views.py api/experimentation/*.py; do
  if [ -f "$f" ] && rg -n "verify_clickhouse_connection|write_environment_ingestion_keys\.delay|perform_create|perform_update|AFTER_CREATE" "$f" >/dev/null; then
    echo "--- $f ---"
    sed -n '1,260p' "$f" | rg -n -A40 -B20 "verify_clickhouse_connection|write_environment_ingestion_keys\.delay|perform_create|perform_update|AFTER_CREATE"
  fi
done

Repository: Flagsmith/flagsmith

Length of output: 18677


Move ClickHouse verification off the request thread Up to ~35s of network I/O still runs inline in perform_create/perform_update, so a slow or unreachable ClickHouse host can block API workers on every create/update. Queue this asynchronously and update status/status_detail out of band.



def refresh_warehouse_connection_status(
connection: WarehouseConnection,
stats: WarehouseEventStats,
Expand Down
21 changes: 21 additions & 0 deletions api/experimentation/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,24 @@ class SnowflakeConfig(TypedDict):
"role": "FLAGSMITH_LOADER",
"user": "FLAGSMITH_SERVICE",
}


class ClickHouseConfig(TypedDict):
host: str
port: int
database: str
username: str
secure: bool


CLICKHOUSE_DEFAULTS: ClickHouseConfig = {
"host": "",
"port": 9440,
"database": "flagsmith",
"username": "default",
"secure": True,
}


class ClickHouseCredentials(TypedDict):
password: str
18 changes: 18 additions & 0 deletions api/experimentation/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import BaseSerializer
from rest_framework.throttling import BaseThrottle, ScopedRateThrottle
from rest_framework.viewsets import GenericViewSet

from app.pagination import CustomPagination
Expand Down Expand Up @@ -62,6 +63,7 @@
mark_warehouse_pending_connection,
refresh_warehouse_connection_status,
transition_experiment_status,
verify_clickhouse_connection,
)
from experimentation.tasks import (
compute_experiment_exposures,
Expand Down Expand Up @@ -89,19 +91,32 @@ class WarehouseConnectionViewSet(
lookup_field = "id"
lookup_url_kwarg = "connection_id"

def get_throttles(self) -> list[BaseThrottle]:
if self.action == "test_warehouse_connection":
self.throttle_scope = "warehouse_connection_test"
return [ScopedRateThrottle()]
return super().get_throttles()
Comment on lines +94 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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== views.py ==\n'
sed -n '1,180p' api/experimentation/views.py

printf '\n== test_views.py around throttle test ==\n'
sed -n '1020,1100p' api/tests/unit/experimentation/test_views.py

printf '\n== search for get_throttles definitions/usages ==\n'
rg -n "def get_throttles|throttle_scope|ScopedRateThrottle|throttle_classes" api/experimentation api/tests/unit/experimentation -S

Repository: Flagsmith/flagsmith

Length of output: 10305


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,180p' api/experimentation/views.py
sed -n '1020,1100p' api/tests/unit/experimentation/test_views.py
rg -n "def get_throttles|throttle_scope|ScopedRateThrottle|throttle_classes" api/experimentation api/tests/unit/experimentation -S

Repository: Flagsmith/flagsmith

Length of output: 10196


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "DEFAULT_THROTTLE_CLASSES|throttle_classes\s*=" -S .

Repository: Flagsmith/flagsmith

Length of output: 1775


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== NestedEnvironmentViewSet definition ==\n'
rg -n "class NestedEnvironmentViewSet|throttle_classes|get_throttles" api environments core -S

printf '\n== likely file for NestedEnvironmentViewSet ==\n'
fd -a "views.py" api environments core | rg "environments/views.py|NestedEnvironmentViewSet|views.py$"

Repository: Flagsmith/flagsmith

Length of output: 2911


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
for p in Path('.').rglob('*.py'):
    try:
        text = p.read_text()
    except Exception:
        continue
    if 'class NestedEnvironmentViewSet' in text:
        print(p)
PY

Repository: Flagsmith/flagsmith

Length of output: 183


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '300,380p' api/environments/views.py

Repository: Flagsmith/flagsmith

Length of output: 3267


Preserve the existing throttle stack for write actions. In api/experimentation/views.py#L94-L103, append ScopedRateThrottle to super().get_throttles() instead of returning it on its own, so any configured throttles still apply. In api/tests/unit/experimentation/test_views.py#L1054-L1069, assert the warehouse scope is present without requiring it to be the only throttle.

📍 Affects 2 files
  • api/experimentation/views.py#L94-L103 (this comment)
  • api/tests/unit/experimentation/test_views.py#L1054-L1069


def perform_create(self, serializer: BaseSerializer[WarehouseConnection]) -> None:
connection: WarehouseConnection = serializer.save(
environment=self._get_environment()
)
create_warehouse_audit_log(
connection, self._get_user(self.request), action="created"
)
if connection.warehouse_type == WarehouseType.CLICKHOUSE:
verify_clickhouse_connection(connection)

def perform_update(self, serializer: BaseSerializer[WarehouseConnection]) -> None:
connection: WarehouseConnection = serializer.save()
create_warehouse_audit_log(
connection, self._get_user(self.request), action="updated"
)
if connection.warehouse_type == WarehouseType.CLICKHOUSE and (
"config" in serializer.validated_data
or "credentials" in serializer.validated_data
):
verify_clickhouse_connection(connection)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

def perform_destroy(self, instance: WarehouseConnection) -> None:
create_warehouse_audit_log(
Expand Down Expand Up @@ -130,6 +145,9 @@ def retrieve(self, request: Request, *args: object, **kwargs: object) -> Respons
@action(detail=True, methods=["post"], url_path="test-warehouse-connection")
def test_warehouse_connection(self, request: Request, **kwargs: object) -> Response:
connection: WarehouseConnection = self.get_object()
if connection.warehouse_type == WarehouseType.CLICKHOUSE:
verify_clickhouse_connection(connection)
return Response(self.get_serializer(connection).data)
if connection.warehouse_type != WarehouseType.FLAGSMITH:
return Response(
{"detail": "Test events are only supported for Flagsmith warehouses."},
Expand Down
Loading
Loading