Skip to content

Commit c25dc8c

Browse files
feat: Mixpanel cohort sync webhook (#8338)
Co-authored-by: flagsmith-engineering[bot] <flagsmith-engineering[bot]@users.noreply.github.com>
1 parent c0b2a31 commit c25dc8c

15 files changed

Lines changed: 1103 additions & 31 deletions

File tree

api/api/openapi.py

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -165,19 +165,38 @@ def get_security_definition(
165165

166166
class CohortSyncKeyAuthenticationExtension(OpenApiAuthenticationExtension): # type: ignore[no-untyped-call]
167167
target_class = "cohorts.authentication.CohortSyncKeyAuthentication"
168-
name = "Cohort Sync Key"
168+
name = ["Cohort Sync Key", "Cohort Sync Key (Basic)"]
169+
170+
def get_security_requirement(
171+
self, auto_schema: openapi.AutoSchema
172+
) -> list[dict[str, list[Any]]]:
173+
# Separate entries: the caller sends the key with either scheme,
174+
# not both at once.
175+
return [{name: []} for name in self.name]
169176

170177
def get_security_definition(
171178
self, auto_schema: openapi.AutoSchema | None = None
172-
) -> dict[str, Any]:
173-
return {
174-
"type": "http",
175-
"scheme": "bearer",
176-
"description": (
177-
"For cohort sync endpoints called by an external cohort "
178-
"source, such as Amplitude."
179-
),
180-
}
179+
) -> list[dict[str, Any]]:
180+
return [
181+
{
182+
"type": "http",
183+
"scheme": "bearer",
184+
"description": (
185+
"For cohort sync endpoints called by an external cohort "
186+
"source, such as Amplitude."
187+
),
188+
},
189+
{
190+
"type": "http",
191+
"scheme": "basic",
192+
"description": (
193+
"For cohort sync endpoints called by an external cohort "
194+
"source that can only send Basic credentials, such as "
195+
"Mixpanel. The key is the password; the username is "
196+
"ignored."
197+
),
198+
},
199+
]
181200

182201

183202
# Tag definitions controlling the order and display of sections in the Swagger UI.

api/cohorts/authentication.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import typing
1+
import base64
22
from contextlib import suppress
33

44
from django.contrib.auth.models import AnonymousUser
@@ -9,18 +9,36 @@
99

1010

1111
class CohortSyncKeyAuthentication(authentication.BaseAuthentication):
12+
"""
13+
Accepts a cohort sync key sent either as a Bearer token or as the
14+
password of Basic credentials. Amplitude sends Bearer; Mixpanel's
15+
webhook setup only offers a username/password form, so its customers
16+
enter any username and the key as the password. The username is
17+
ignored.
18+
"""
19+
1220
def authenticate(
1321
self, request: Request
1422
) -> tuple[AnonymousUser, CohortSyncKey] | None:
1523
header = request.headers.get("Authorization", "")
16-
if not header.startswith("Bearer "):
24+
if header.startswith("Bearer "):
25+
raw_key = header.removeprefix("Bearer ")
26+
elif header.startswith("Basic "):
27+
try:
28+
decoded = base64.b64decode(
29+
header.removeprefix("Basic "), validate=True
30+
).decode()
31+
except ValueError:
32+
# Covers malformed base64, header bytes outside ASCII, and
33+
# decoded credentials that are not valid UTF-8.
34+
raise exceptions.AuthenticationFailed("Invalid Basic credentials.")
35+
# Split at the first colon, so a key containing colons survives.
36+
_, _, raw_key = decoded.partition(":")
37+
else:
1738
return None
1839

1940
with suppress(CohortSyncKey.DoesNotExist):
20-
key = typing.cast(
21-
CohortSyncKey,
22-
CohortSyncKey.objects.get_from_key(header.removeprefix("Bearer ")),
23-
)
41+
key = CohortSyncKey.objects.get_from_key(raw_key)
2442
if not key.has_expired:
2543
# No person is acting here, so no user is returned: the key
2644
# alone carries authority, and audit trails record the source
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Generated by Django 5.2.16 on 2026-08-20 10:06
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("cohorts", "0003_cohort_sync_key"),
10+
("environments", "0039_use_no_ssrf_url_field"),
11+
("segments", "0032_add_segment_rules_data"),
12+
]
13+
14+
operations = [
15+
migrations.AddField(
16+
model_name="cohort",
17+
name="external_id",
18+
field=models.CharField(blank=True, max_length=255, null=True),
19+
),
20+
migrations.AlterField(
21+
model_name="cohort",
22+
name="source_type",
23+
field=models.CharField(
24+
choices=[
25+
("csv", "CSV"),
26+
("amplitude", "Amplitude"),
27+
("mixpanel", "Mixpanel"),
28+
],
29+
default="csv",
30+
max_length=50,
31+
),
32+
),
33+
]

api/cohorts/models.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import typing
2+
13
from django.db import models
2-
from rest_framework_api_key.models import AbstractAPIKey
4+
from rest_framework_api_key.models import AbstractAPIKey, APIKeyManager
35

46
from cohorts.constants import COHORT_SYSTEM_TRAIT_KEY_PREFIX
57
from core.models import SoftDeleteExportableModel
@@ -8,6 +10,7 @@
810
class CohortSourceType(models.TextChoices):
911
CSV = "csv", "CSV"
1012
AMPLITUDE = "amplitude", "Amplitude"
13+
MIXPANEL = "mixpanel", "Mixpanel"
1114

1215

1316
class Cohort(SoftDeleteExportableModel):
@@ -26,6 +29,11 @@ class Cohort(SoftDeleteExportableModel):
2629
choices=CohortSourceType.choices,
2730
default=CohortSourceType.CSV,
2831
)
32+
# The cohort's identifier in the external source. Mixpanel pushes under
33+
# its own cohort ID, so we store it to route later requests; Amplitude
34+
# uses the ID we hand back at list creation, and CSV cohorts have no
35+
# external system, so both leave this null.
36+
external_id = models.CharField(max_length=255, null=True, blank=True)
2937
version = models.PositiveIntegerField(default=0)
3038
created_at = models.DateTimeField(auto_now_add=True)
3139
# Deletion drains memberships from the identity store first; the cohort is
@@ -48,7 +56,19 @@ class Meta:
4856
]
4957

5058

59+
class CohortSyncKeyManager(APIKeyManager):
60+
def get_from_key(self, key: str) -> "CohortSyncKey":
61+
if "\x00" in key:
62+
# A NUL can't travel in a raw header, but base64 credentials can
63+
# decode to one, and the database driver refuses to build a query
64+
# containing it. No real key holds one, so treat it as absent.
65+
raise self.model.DoesNotExist("Key contains a NUL character.")
66+
return typing.cast("CohortSyncKey", super().get_from_key(key))
67+
68+
5169
class CohortSyncKey(AbstractAPIKey):
70+
objects: typing.ClassVar[CohortSyncKeyManager] = CohortSyncKeyManager()
71+
5272
environment = models.ForeignKey(
5373
"environments.Environment",
5474
on_delete=models.CASCADE,

api/cohorts/serializers.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,33 @@ def _validate_identifier_byte_length(value: str) -> None:
9898
)
9999

100100

101+
class MixpanelMemberSerializer(serializers.Serializer[None]):
102+
mixpanel_distinct_id = serializers.CharField(
103+
validators=[_validate_identifier_byte_length]
104+
)
105+
106+
107+
class MixpanelParametersSerializer(serializers.Serializer[None]):
108+
mixpanel_cohort_id = serializers.CharField(max_length=255)
109+
mixpanel_cohort_name = serializers.CharField(max_length=2000)
110+
# An empty page is valid: a first sync of an empty cohort has no members.
111+
# Mixpanel sends at most 1000 members per message; the cap stops anything
112+
# else from posting an arbitrarily large page.
113+
# The stubs don't know many=True forwards max_length to the list serialiser.
114+
members = MixpanelMemberSerializer( # type: ignore[call-arg]
115+
many=True, allow_empty=True, max_length=1000
116+
)
117+
118+
119+
class MixpanelWebhookSerializer(serializers.Serializer[None]):
120+
# "members" carries the full membership on the first sync;
121+
# "add_members"/"remove_members" carry changes since the last sync.
122+
action = serializers.ChoiceField(
123+
choices=["members", "add_members", "remove_members"]
124+
)
125+
parameters = MixpanelParametersSerializer()
126+
127+
101128
class CohortSyncMembersSerializer(serializers.Serializer[None]):
102129
user_ids = serializers.ListField(
103130
child=serializers.CharField(validators=[_validate_identifier_byte_length]),

api/cohorts/services.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ def create_cohort(
116116
name: str,
117117
description: str | None = None,
118118
source_type: CohortSourceType = CohortSourceType.CSV,
119+
external_id: str | None = None,
119120
) -> Cohort:
120121
project = environment.project
121122
# Mirrors the segment limit enforced by SegmentSerializer, which cohort
@@ -137,7 +138,10 @@ def create_cohort(
137138
)
138139
rule = SegmentRule.objects.create(segment=segment, type=SegmentRule.ALL_RULE)
139140
cohort: Cohort = Cohort.objects.create(
140-
environment=environment, segment=segment, source_type=source_type
141+
environment=environment,
142+
segment=segment,
143+
source_type=source_type,
144+
external_id=external_id,
141145
)
142146
Condition.objects.create(
143147
rule=rule,
@@ -161,10 +165,16 @@ def create_cohort_for_source(
161165
environment: "Environment",
162166
name: str,
163167
source_type: CohortSourceType,
168+
external_id: str | None = None,
164169
) -> Cohort:
165170
"""Create a cohort on behalf of an external source, where no Flagsmith
166171
user is acting."""
167-
cohort = create_cohort(environment=environment, name=name, source_type=source_type)
172+
cohort = create_cohort(
173+
environment=environment,
174+
name=name,
175+
source_type=source_type,
176+
external_id=external_id,
177+
)
168178
# Nothing records a user for these calls, so the audit log that Flagsmith
169179
# derives from historical records is skipped — and with it the environment
170180
# document rebuild that makes the new segment visible to SDKs. Write the
@@ -182,6 +192,37 @@ def create_cohort_for_source(
182192
return cohort
183193

184194

195+
def get_cohort_for_source(
196+
*,
197+
environment: "Environment",
198+
source_type: CohortSourceType,
199+
external_id: str,
200+
) -> Cohort | None:
201+
cohort: Cohort | None = Cohort.objects.filter(
202+
environment=environment,
203+
source_type=source_type,
204+
external_id=external_id,
205+
deletion_requested_at__isnull=True,
206+
).first()
207+
return cohort
208+
209+
210+
def cohort_deletion_in_progress(
211+
*,
212+
environment: "Environment",
213+
source_type: CohortSourceType,
214+
external_id: str,
215+
) -> bool:
216+
return bool(
217+
Cohort.objects.filter(
218+
environment=environment,
219+
source_type=source_type,
220+
external_id=external_id,
221+
deletion_requested_at__isnull=False,
222+
).exists()
223+
)
224+
225+
185226
def add_cohort_members(cohort: Cohort, identifiers: "typing.Iterable[str]") -> None:
186227
from cohorts.tasks import apply_cohort_membership_deltas
187228

api/cohorts/sync_urls.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
1+
from django.urls import path
12
from rest_framework.routers import SimpleRouter
23

3-
from cohorts.sync_views import AmplitudeCohortSyncViewSet
4+
from cohorts.sync_views import AmplitudeCohortSyncViewSet, MixpanelCohortSyncView
45

56
app_name = "cohort-sync"
67

78
# SimpleRouter: nothing here is browsed by a person.
89
router = SimpleRouter()
910
router.register(r"amplitude/lists", AmplitudeCohortSyncViewSet, basename="amplitude")
1011

11-
urlpatterns = router.urls
12+
urlpatterns = [
13+
path("mixpanel/webhook/", MixpanelCohortSyncView.as_view(), name="mixpanel"),
14+
*router.urls,
15+
]

0 commit comments

Comments
 (0)