Skip to content

Commit e37b7cf

Browse files
committed
fix(cohorts): harden sync key auth and drop the external ID constraint
1 parent 753f5db commit e37b7cf

8 files changed

Lines changed: 32 additions & 177 deletions

File tree

api/cohorts/authentication.py

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import base64
2-
import typing
32
from contextlib import suppress
43

54
from django.contrib.auth.models import AnonymousUser
@@ -38,16 +37,8 @@ def authenticate(
3837
else:
3938
return None
4039

41-
if "\x00" in raw_key:
42-
# Postgres refuses to run a query containing a NUL character, so
43-
# the key lookup below would crash instead of returning 401.
44-
raise exceptions.AuthenticationFailed("Valid cohort sync key not found.")
45-
4640
with suppress(CohortSyncKey.DoesNotExist):
47-
key = typing.cast(
48-
CohortSyncKey,
49-
CohortSyncKey.objects.get_from_key(raw_key),
50-
)
41+
key = CohortSyncKey.objects.get_from_key(raw_key)
5142
if not key.has_expired:
5243
# No person is acting here, so no user is returned: the key
5344
# alone carries authority, and audit trails record the source

api/cohorts/migrations/0004_mixpanel_source.py

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,4 @@ class Migration(migrations.Migration):
3030
max_length=50,
3131
),
3232
),
33-
migrations.AddConstraint(
34-
model_name="cohort",
35-
constraint=models.UniqueConstraint(
36-
condition=models.Q(
37-
("deleted_at__isnull", True), ("external_id__isnull", False)
38-
),
39-
fields=("environment", "source_type", "external_id"),
40-
name="unique_active_cohort_per_source_external_id",
41-
),
42-
),
4333
]

api/cohorts/models.py

Lines changed: 19 additions & 13 deletions
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
@@ -27,9 +29,10 @@ class Cohort(SoftDeleteExportableModel):
2729
choices=CohortSourceType.choices,
2830
default=CohortSourceType.CSV,
2931
)
30-
# The cohort's identifier in the external source (e.g. Mixpanel's cohort
31-
# ID). Set for sources that push to us under their own identifier; null
32-
# for sources that adopt ours (Amplitude) and for CSV cohorts.
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.
3336
external_id = models.CharField(max_length=255, null=True, blank=True)
3437
version = models.PositiveIntegerField(default=0)
3538
created_at = models.DateTimeField(auto_now_add=True)
@@ -50,19 +53,22 @@ class Meta:
5053
condition=models.Q(deleted_at__isnull=True),
5154
name="unique_active_cohort_per_segment",
5255
),
53-
# One Mixpanel cohort must map to one active cohort per
54-
# environment: without this, two simultaneous first-sync requests
55-
# would each create their own cohort and split the members
56-
# between them.
57-
models.UniqueConstraint(
58-
fields=["environment", "source_type", "external_id"],
59-
condition=models.Q(deleted_at__isnull=True, external_id__isnull=False),
60-
name="unique_active_cohort_per_source_external_id",
61-
),
6256
]
6357

6458

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+
6569
class CohortSyncKey(AbstractAPIKey):
70+
objects: typing.ClassVar[CohortSyncKeyManager] = CohortSyncKeyManager()
71+
6672
environment = models.ForeignKey(
6773
"environments.Environment",
6874
on_delete=models.CASCADE,

api/cohorts/services.py

Lines changed: 1 addition & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import typing
22

33
import structlog
4-
from django.db import IntegrityError, transaction
4+
from django.db import transaction
55
from django.db.models import QuerySet
66
from django.utils import timezone
77
from flag_engine.segments.constants import IS_SET
@@ -170,38 +170,6 @@ def get_cohort_for_source(
170170
return cohort
171171

172172

173-
def get_or_create_cohort_for_source(
174-
*,
175-
environment: "Environment",
176-
name: str,
177-
source_type: CohortSourceType,
178-
external_id: str,
179-
) -> Cohort | None:
180-
if cohort := get_cohort_for_source(
181-
environment=environment, source_type=source_type, external_id=external_id
182-
):
183-
return cohort
184-
try:
185-
return create_cohort_for_source(
186-
environment=environment,
187-
name=name,
188-
source_type=source_type,
189-
external_id=external_id,
190-
)
191-
except IntegrityError:
192-
# Two situations end up here. A simultaneous first-sync request
193-
# created the cohort between our lookup and our insert — use the one
194-
# it created. Or the cohort was deleted in Flagsmith and is still
195-
# draining memberships from identity data: the lookup doesn't see it,
196-
# but it still occupies the external ID — nothing usable exists, so
197-
# return None.
198-
if cohort := get_cohort_for_source(
199-
environment=environment, source_type=source_type, external_id=external_id
200-
):
201-
return cohort
202-
return None
203-
204-
205173
def add_cohort_members(cohort: Cohort, identifiers: "typing.Iterable[str]") -> None:
206174
from cohorts.tasks import apply_cohort_membership_deltas
207175

api/cohorts/sync_views.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -141,17 +141,17 @@ def post(self, request: Request) -> Response:
141141
# A large first sync arrives as several requests, each one page
142142
# of members. Every page only adds; removals can't be detected
143143
# without seeing all pages at once.
144-
cohort_or_none = services.get_or_create_cohort_for_source(
144+
cohort = services.get_cohort_for_source(
145+
environment=environment,
146+
source_type=CohortSourceType.MIXPANEL,
147+
external_id=parameters["mixpanel_cohort_id"],
148+
) or services.create_cohort_for_source(
145149
environment=environment,
146150
name=parameters["mixpanel_cohort_name"],
147151
source_type=CohortSourceType.MIXPANEL,
148152
external_id=parameters["mixpanel_cohort_id"],
149153
)
150-
if cohort_or_none is None:
151-
return self._failure(
152-
request, message="Cohort is being deleted.", code=404
153-
)
154-
services.add_cohort_members(cohort_or_none, identifiers)
154+
services.add_cohort_members(cohort, identifiers)
155155
else:
156156
cohort_or_none = services.get_cohort_for_source(
157157
environment=environment,

api/tests/unit/cohorts/test_services.py

Lines changed: 1 addition & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
from django.db import IntegrityError
2-
from django.utils import timezone
31
from flag_engine.segments.constants import IS_SET
42
from pytest_mock import MockerFixture
53
from pytest_structlog import StructuredLogCapture
@@ -8,18 +6,16 @@
86
Cohort,
97
CohortMembership,
108
CohortMembershipState,
11-
CohortSourceType,
129
)
1310
from cohorts.services import (
1411
apply_pending_memberships,
1512
create_cohort,
1613
delete_cohort,
17-
get_or_create_cohort_for_source,
1814
)
1915
from environments.dynamodb import DynamoIdentityWrapper
2016
from environments.identities.models import Identity
2117
from environments.models import Environment
22-
from segments.models import Segment, SegmentManagedBy, SegmentRule
18+
from segments.models import SegmentManagedBy, SegmentRule
2319

2420

2521
def test_apply_pending_memberships__no_pending_rows__returns_false(
@@ -289,64 +285,3 @@ def test_apply_pending_memberships__system_trait_already_unset__deletes_membersh
289285
identity.refresh_from_db()
290286
assert identity.system_traits == {"flagsmith_cohort_other": True}
291287
assert not CohortMembership.objects.filter(cohort=cohort).exists()
292-
293-
294-
def test_get_or_create_cohort_for_source__simultaneous_creation__returns_other_requests_cohort(
295-
environment: Environment,
296-
mocker: MockerFixture,
297-
) -> None:
298-
# Given - creating the cohort fails because another request created its
299-
# own cohort between our lookup and our insert
300-
def create_winning_cohort_and_conflict(**kwargs: object) -> Cohort:
301-
segment = Segment.objects.create(
302-
name="Power users", project=environment.project
303-
)
304-
Cohort.objects.create(
305-
environment=environment,
306-
segment=segment,
307-
source_type=CohortSourceType.MIXPANEL,
308-
external_id="mp-42",
309-
)
310-
raise IntegrityError("unique_active_cohort_per_source_external_id")
311-
312-
mocker.patch(
313-
"cohorts.services.create_cohort_for_source",
314-
side_effect=create_winning_cohort_and_conflict,
315-
)
316-
317-
# When
318-
cohort = get_or_create_cohort_for_source(
319-
environment=environment,
320-
name="Power users",
321-
source_type=CohortSourceType.MIXPANEL,
322-
external_id="mp-42",
323-
)
324-
325-
# Then
326-
assert cohort == Cohort.objects.get(external_id="mp-42")
327-
328-
329-
def test_get_or_create_cohort_for_source__deletion_requested_same_external_id__returns_none(
330-
environment: Environment,
331-
) -> None:
332-
# Given - a cohort with the same external ID is awaiting deletion, so it
333-
# is invisible to the lookup but still occupies the external ID
334-
segment = Segment.objects.create(name="Power users", project=environment.project)
335-
Cohort.objects.create(
336-
environment=environment,
337-
segment=segment,
338-
source_type=CohortSourceType.MIXPANEL,
339-
external_id="mp-42",
340-
deletion_requested_at=timezone.now(),
341-
)
342-
343-
# When
344-
cohort = get_or_create_cohort_for_source(
345-
environment=environment,
346-
name="Power users",
347-
source_type=CohortSourceType.MIXPANEL,
348-
external_id="mp-42",
349-
)
350-
351-
# Then
352-
assert cohort is None

api/tests/unit/cohorts/test_sync_views.py

Lines changed: 0 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -696,41 +696,6 @@ def test_mixpanel_webhook__unparseable_body__returns_400_failure(
696696
}
697697

698698

699-
def test_mixpanel_webhook__members_action_while_cohort_draining__returns_404_failure(
700-
postgres_cohort_sync_key: _KeyAndPlaintext,
701-
mixpanel_cohort: Cohort,
702-
) -> None:
703-
# Given - the cohort was deleted in Flagsmith and is still draining
704-
mixpanel_cohort.deletion_requested_at = timezone.now()
705-
mixpanel_cohort.save()
706-
_, plaintext = postgres_cohort_sync_key
707-
client = _basic_auth_client(plaintext)
708-
url = reverse("api-v1:cohort-sync:mixpanel")
709-
710-
# When
711-
response = client.post(
712-
url,
713-
data={
714-
"action": "members",
715-
"parameters": {
716-
"mixpanel_cohort_id": "mp-42",
717-
"mixpanel_cohort_name": "Power users",
718-
"members": [{"mixpanel_distinct_id": "user-1"}],
719-
},
720-
},
721-
format="json",
722-
)
723-
724-
# Then
725-
assert response.status_code == status.HTTP_404_NOT_FOUND
726-
assert response.json() == {
727-
"action": "members",
728-
"status": "failure",
729-
"error": {"message": "Cohort is being deleted.", "code": 404},
730-
}
731-
assert Cohort.objects.filter(source_type=CohortSourceType.MIXPANEL).count() == 1
732-
733-
734699
def test_mixpanel_webhook__other_environment_key__returns_404_failure(
735700
mixpanel_cohort: Cohort,
736701
) -> None:

docs/docs/deployment-self-hosting/observability/_events-catalogue.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ Attributes:
8686
### `cohorts.cohort.deleted`
8787

8888
Logged at `info` from:
89-
- `api/cohorts/services.py:276`
89+
- `api/cohorts/services.py:244`
9090

9191
Attributes:
9292
- `cohort.id`
@@ -95,7 +95,7 @@ Attributes:
9595
### `cohorts.cohort.deletion_requested`
9696

9797
Logged at `info` from:
98-
- `api/cohorts/services.py:260`
98+
- `api/cohorts/services.py:228`
9999

100100
Attributes:
101101
- `cohort.id`
@@ -132,8 +132,8 @@ Attributes:
132132
### `cohorts.membership.deltas_received`
133133

134134
Logged at `info` from:
135-
- `api/cohorts/services.py:225`
136-
- `api/cohorts/services.py:244`
135+
- `api/cohorts/services.py:193`
136+
- `api/cohorts/services.py:212`
137137

138138
Attributes:
139139
- `action`

0 commit comments

Comments
 (0)