diff --git a/src/sentry/flags/endpoints/secrets.py b/src/sentry/flags/endpoints/secrets.py index a277e70a103a03..11f2d511d39fcd 100644 --- a/src/sentry/flags/endpoints/secrets.py +++ b/src/sentry/flags/endpoints/secrets.py @@ -43,9 +43,19 @@ def serialize(self, obj, attrs, user, **kwargs) -> FlagWebhookSigningSecretRespo class FlagWebhookSigningSecretValidator(serializers.Serializer): provider = serializers.ChoiceField( - choices=["launchdarkly", "generic", "unleash"], required=True + choices=["launchdarkly", "generic", "unleash", "statsig"], required=True ) - secret = serializers.CharField(required=True, max_length=32, min_length=32) + secret = serializers.CharField(required=True) + + def validate_secret(self, value): + if self.initial_data.get("provider") == "statsig": + if not value.startswith("webhook-"): + raise serializers.ValidationError( + "Ensure this field is of the format webhook-" + ) + return serializers.CharField(min_length=32, max_length=64).run_validation(value) + + return serializers.CharField(min_length=32, max_length=32).run_validation(value) @region_silo_endpoint diff --git a/src/sentry/flags/providers.py b/src/sentry/flags/providers.py index 312aadef3b619e..40349afe50ee2b 100644 --- a/src/sentry/flags/providers.py +++ b/src/sentry/flags/providers.py @@ -15,6 +15,7 @@ FlagWebHookSigningSecretModel, ) from sentry.silo.base import SiloLimit +from sentry.utils.safe import get_path def write(rows: list["FlagAuditLogRow"]) -> None: @@ -54,7 +55,9 @@ class ProviderProtocol(Protocol[T]): provider_name: str signature: str | None - def __init__(self, organization_id: int, signature: str | None) -> None: ... + def __init__( + self, organization_id: int, signature: str | None, request_timestamp: str | None + ) -> None: ... def handle(self, message: T) -> list[FlagAuditLogRow]: ... def validate(self, message_bytes: bytes) -> bool: ... @@ -82,6 +85,12 @@ def get_provider( return GenericProvider(organization_id, signature=headers.get("X-Sentry-Signature")) case "unleash": return UnleashProvider(organization_id, signature=headers.get("Authorization")) + case "statsig": + return StatsigProvider( + organization_id, + signature=headers.get("X-Statsig-Signature"), + request_timestamp=headers.get("X-Statsig-Request-Timestamp"), + ) case _: return None @@ -121,7 +130,9 @@ class LaunchDarklyItemSerializer(serializers.Serializer): class LaunchDarklyProvider: provider_name = "launchdarkly" - def __init__(self, organization_id: int, signature: str | None) -> None: + def __init__( + self, organization_id: int, signature: str | None, _request_timestamp: str | None = None + ) -> None: self.organization_id = organization_id self.signature = signature @@ -208,7 +219,9 @@ class GenericRequestSerializer(serializers.Serializer): class GenericProvider: provider_name = "generic" - def __init__(self, organization_id: int, signature: str | None) -> None: + def __init__( + self, organization_id: int, signature: str | None, _request_timestamp: str | None = None + ) -> None: self.organization_id = organization_id self.signature = signature @@ -300,7 +313,9 @@ def _get_user(validated_event: dict[str, Any]) -> tuple[str, int]: class UnleashProvider: provider_name = "unleash" - def __init__(self, organization_id: int, signature: str | None) -> None: + def __init__( + self, organization_id: int, signature: str | None, _request_timestamp: str | None = None + ) -> None: self.organization_id = organization_id self.signature = signature @@ -355,6 +370,130 @@ def _handle_unleash_actions(action: str) -> int: return ACTION_MAP["updated"] +"""Statsig provider.""" + +SUPPORTED_STATSIG_EVENTS = {"statsig::config_change"} + +# Case-insensitive set. Config_change is subclassed by the type of Statsig +# feature. There's "Gate", "Experiment", and more. Feature gates are boolean +# release flags, but all other types are unstructured JSON. To reduce noise, +# Gate is the only type we audit for now. +SUPPORTED_STATSIG_TYPES = { + "gate", +} + + +class StatsigEventSerializer(serializers.Serializer): + eventName = serializers.CharField(required=True) + timestamp = serializers.CharField(required=True) + metadata = serializers.DictField(required=True) + + user = serializers.DictField(required=False, child=serializers.CharField()) + userID = serializers.CharField(required=False) + value = serializers.CharField(required=False) + statsigMetadata = serializers.DictField(required=False) + timeUUID = serializers.UUIDField(required=False) + unitID = serializers.CharField(required=False) + + +class StatsigItemSerializer(serializers.Serializer): + data = serializers.ListField(child=StatsigEventSerializer(), required=True) # type: ignore[assignment] + + +class StatsigProvider: + provider_name = "statsig" + version = "v0" + + def __init__( + self, + organization_id: int, + signature: str | None, + request_timestamp: str | None, + ) -> None: + self.organization_id = organization_id + self.signature = signature + self.request_timestamp = request_timestamp + + # Strip the signature's version prefix. For example, signature format for v0 is "v0+{hash}" + prefix_len = len(self.version) + 1 + if signature and len(signature) > prefix_len: + self.signature = signature[prefix_len:] + + def handle(self, message: dict[str, Any]) -> list[FlagAuditLogRow]: + serializer = StatsigItemSerializer(data=message) + if not serializer.is_valid(): + raise DeserializationError(serializer.errors) + + events = serializer.validated_data["data"] + audit_logs = [] + for event in events: + event_name = event["eventName"] + + if event_name not in SUPPORTED_STATSIG_EVENTS: + continue + + metadata = event.get("metadata") or {} + flag = metadata.get("name") + statsig_type = metadata.get("type") + action = (metadata.get("action") or "").lower() + + if ( + not flag + or not statsig_type + or statsig_type.lower() not in SUPPORTED_STATSIG_TYPES + or action not in ACTION_MAP + ): + continue + + action = ACTION_MAP[action] + + # Prioritize email > id > name for created_by. + if created_by := get_path(event, "user", "email"): + created_by_type = CREATED_BY_TYPE_MAP["email"] + elif created_by := event.get("userID") or get_path(event, "user", "userID"): + created_by_type = CREATED_BY_TYPE_MAP["id"] + elif created_by := get_path(event, "user", "name"): + created_by_type = CREATED_BY_TYPE_MAP["name"] + else: + created_by, created_by_type = None, None + + created_at_ms = float(event["timestamp"]) + created_at = datetime.datetime.fromtimestamp(created_at_ms / 1000.0, datetime.UTC) + + tags = {} + if projectName := metadata.get("projectName"): + tags["projectName"] = projectName + if projectID := metadata.get("projectID"): + tags["projectID"] = projectID + if environments := metadata.get("environments"): + tags["environments"] = environments + + audit_logs.append( + FlagAuditLogRow( + action=action, + created_at=created_at, + created_by=created_by, + created_by_type=created_by_type, + flag=flag, + organization_id=self.organization_id, + tags=tags, + ) + ) + + return audit_logs + + def validate(self, message_bytes: bytes) -> bool: + if self.request_timestamp is None: + return False + + signature_basestring = f"{self.version}:{self.request_timestamp}:".encode() + message_bytes + + validator = PayloadSignatureValidator( + self.organization_id, self.provider_name, signature_basestring, self.signature + ) + return validator.validate() + + """Flagpole provider.""" @@ -389,10 +528,11 @@ def handle_flag_pole_event_internal(items: list[FlagAuditLogItem], organization_ class AuthTokenValidator: - """Abstract payload validator. + """Abstract validator for injecting dependencies in tests. Use this when a + provider does not support signing. - Similar to the SecretValidator class below, except we do not need - to validate the authorization string. + Similar to the PayloadSignatureValidator class below, except we do not + validate the authorization string with the payload. """ def __init__( @@ -419,7 +559,7 @@ def validate(self) -> bool: class PayloadSignatureValidator: - """Abstract payload validator. + """Abstract payload validator. Uses HMAC-SHA256 by default. Allows us to inject dependencies for differing use cases. Specifically the test suite. @@ -429,14 +569,14 @@ def __init__( self, organization_id: int, provider: str, - request_body: bytes, + message: bytes, signature: str | None, secret_finder: Callable[[int, str], Iterator[str]] | None = None, secret_validator: Callable[[str, bytes], str] | None = None, ) -> None: self.organization_id = organization_id self.provider = provider - self.request_body = request_body + self.message = message self.signature = signature self.secret_finder = secret_finder or _query_signing_secrets self.secret_validator = secret_validator or hmac_sha256_hex_digest @@ -446,7 +586,7 @@ def validate(self) -> bool: return False for secret in self.secret_finder(self.organization_id, self.provider): - if self.secret_validator(secret, self.request_body) == self.signature: + if self.secret_validator(secret, self.message) == self.signature: return True return False diff --git a/tests/sentry/flags/endpoints/test_hooks.py b/tests/sentry/flags/endpoints/test_hooks.py index 52f6dc5f7d7de9..9822ec9e6c611f 100644 --- a/tests/sentry/flags/endpoints/test_hooks.py +++ b/tests/sentry/flags/endpoints/test_hooks.py @@ -86,6 +86,56 @@ def test_unleash_post_create(self, mock_incr): ) assert FlagAuditLogModel.objects.count() == 1 + def test_statsig_post_create(self, mock_incr): + request_data = { + "data": [ + { + "user": {"name": "johndoe", "email": "john@sentry.io"}, + "timestamp": 1739400185198, + "eventName": "statsig::config_change", + "metadata": { + "projectName": "sentry", + "projectID": "1", + "type": "Gate", + "name": "gate1", + "description": "Updated Config Conditions\n - Added rule Rule 1", + "environments": "development,staging,production", + "action": "updated", + "tags": [], + "targetApps": [], + }, + }, + ] + } + + secret = "webhook-Xk9pL8NQaR5Ym2cx7vHnWtBj4M3f6qyZdC12mnspk8" + + FlagWebHookSigningSecretModel.objects.create( + organization=self.organization, + provider="statsig", + secret=secret, + ) + + request_timestamp = "1739400185400" # ms timestamp of the webhook request + signature_basestring = f"v0:{request_timestamp}:{json.dumps(request_data)}".encode() + signature = "v0=" + hmac_sha256_hex_digest(key=secret, message=signature_basestring) + headers = { + "X-Statsig-Signature": signature, + "X-Statsig-Request-Timestamp": request_timestamp, + } + + with self.feature(self.features): + response = self.client.post( + reverse(self.endpoint, args=(self.organization.slug, "statsig")), + request_data, + headers=headers, + ) + assert response.status_code == 200, response.content + mock_incr.assert_any_call( + "feature_flags.audit_log_event_posted", tags={"provider": "statsig"} + ) + assert FlagAuditLogModel.objects.count() == 1 + def test_launchdarkly_post_create(self, mock_incr): request_data = LD_REQUEST signature = hmac_sha256_hex_digest(key="456", message=json.dumps(request_data).encode()) diff --git a/tests/sentry/flags/endpoints/test_secrets.py b/tests/sentry/flags/endpoints/test_secrets.py index c0597fc04d5bb0..4b628e5417bfbf 100644 --- a/tests/sentry/flags/endpoints/test_secrets.py +++ b/tests/sentry/flags/endpoints/test_secrets.py @@ -89,11 +89,26 @@ def test_post_unleash(self): assert len(models) == 1 assert models[0].secret == "41271af8b9804cd99a4c787a28274991" + def test_post_statsig(self): + with self.feature(self.features): + response = self.client.post( + self.url, + data={ + "secret": "webhook-Xk9pL8NQaR5Ym2cx7vHnWtBj4M3f6qyZdC12mnspk8", + "provider": "statsig", + }, + ) + assert response.status_code == 201, response.content + + models = FlagWebHookSigningSecretModel.objects.filter(provider="statsig").all() + assert len(models) == 1 + assert models[0].secret == "webhook-Xk9pL8NQaR5Ym2cx7vHnWtBj4M3f6qyZdC12mnspk8" + def test_post_disabled(self): response = self.client.post(self.url, data={}) assert response.status_code == 404, response.content - def test_post_invalid(self): + def test_post_invalid_provider(self): with self.feature(self.features): url = reverse(self.endpoint, args=(self.organization.id,)) response = self.client.post(url, data={"secret": "123", "provider": "other"}) @@ -101,6 +116,50 @@ def test_post_invalid(self): assert response.json()["provider"] == ['"other" is not a valid choice.'] assert response.json()["secret"] == ["Ensure this field has at least 32 characters."] + def test_post_invalid_secret(self): + with self.feature(self.features): + for provider in ["launchdarkly", "generic", "unleash"]: + response = self.client.post( + self.url, data={"secret": "a" * 31, "provider": provider} + ) + assert response.status_code == 400, response.content + assert response.json()["secret"] == [ + "Ensure this field has at least 32 characters." + ], provider + + response = self.client.post( + self.url, data={"secret": "a" * 33, "provider": provider} + ) + assert response.status_code == 400, response.content + assert response.json()["secret"] == [ + "Ensure this field has no more than 32 characters." + ], provider + + # Statsig + response = self.client.post(self.url, data={"secret": "a" * 32, "provider": "statsig"}) + assert response.status_code == 400, response.content + assert response.json()["secret"] == [ + "Ensure this field is of the format webhook-" + ], "statsig" + + response = self.client.post( + self.url, + data={"secret": "webhook-" + "a" * (31 - len("webhook-")), "provider": "statsig"}, + ) + assert response.status_code == 400, response.content + assert response.json()["secret"] == [ + "Ensure this field has at least 32 characters." + ], "statsig" + + response = self.client.post( + self.url, + data={"secret": "webhook-" + "a" * (65 - len("webhook-")), "provider": "statsig"}, + ) + assert response.status_code == 400, response.content + assert response.json()["secret"] == [ + "Ensure this field has no more than 64 characters." + ], "statsig" + def test_post_empty_request(self): with self.feature(self.features): response = self.client.post(self.url, data={}) diff --git a/tests/sentry/flags/providers/test_statsig.py b/tests/sentry/flags/providers/test_statsig.py new file mode 100644 index 00000000000000..5163880b193088 --- /dev/null +++ b/tests/sentry/flags/providers/test_statsig.py @@ -0,0 +1,341 @@ +import datetime + +from sentry.flags.providers import StatsigProvider + + +def test_handle_batched_all_actions(): + org_id = 123 + logs = StatsigProvider(org_id, "abcdefgh", request_timestamp="1739400185400").handle( + { + "data": [ + { + "user": {"name": "johndoe", "email": "john@sentry.io"}, + "timestamp": 1739400185198, + "eventName": "statsig::config_change", + "metadata": { + "projectName": "sentry", + "projectID": "1", + "type": "Gate", + "name": "gate1", + "description": "Updated Config Conditions\n - Added rule Rule 1", + "environments": "development,staging,production", + "action": "updated", + "tags": [], + "targetApps": [], + }, + }, + { + "user": {"email": "victor@ingolstadt.edu"}, + "timestamp": 17, + "eventName": "statsig::config_change", + "metadata": { + "projectName": "frankenstein", + "projectID": "1700", + "type": "Gate", + "name": "life", + "description": "Wretched", + "environments": "production", + "action": "created", + "tags": [], + "targetApps": [], + }, + }, + { + "user": {"name": "johndoe", "email": "john@sentry.io"}, + "timestamp": 1739400185233, + "eventName": "statsig::config_change", + "metadata": { + "projectName": "sentry", + "projectID": "1", + "type": "Gate", + "name": "gate1", + "environments": "development,staging", + "action": "deleted", + "tags": [], + "targetApps": [], + }, + }, + ] + } + ) + + assert len(logs) == 3 + + assert logs[0]["action"] == 2 + assert logs[0]["created_at"] == datetime.datetime( + 2025, 2, 12, 22, 43, 5, 198000, tzinfo=datetime.UTC + ) + assert logs[0]["created_by"] == "john@sentry.io" + assert logs[0]["created_by_type"] == 0 + assert logs[0]["flag"] == "gate1" + assert logs[0]["organization_id"] == org_id + assert logs[0]["tags"] == { + "projectName": "sentry", + "projectID": "1", + "environments": "development,staging,production", + } + + assert logs[1]["action"] == 0 + assert logs[1]["created_at"] == datetime.datetime( + 1970, 1, 1, 0, 0, 0, 17000, tzinfo=datetime.UTC + ) + assert logs[1]["created_by"] == "victor@ingolstadt.edu" + assert logs[1]["created_by_type"] == 0 + assert logs[1]["flag"] == "life" + assert logs[1]["organization_id"] == org_id + assert logs[1]["tags"] == { + "projectName": "frankenstein", + "projectID": "1700", + "environments": "production", + } + + assert logs[2]["action"] == 1 + assert logs[2]["created_at"] == datetime.datetime( + 2025, 2, 12, 22, 43, 5, 233000, tzinfo=datetime.UTC + ) + assert logs[2]["created_by"] == "john@sentry.io" + assert logs[2]["created_by_type"] == 0 + assert logs[2]["flag"] == "gate1" + assert logs[2]["organization_id"] == org_id + assert logs[2]["tags"] == { + "projectName": "sentry", + "projectID": "1", + "environments": "development,staging", + } + + +def test_handle_no_user(): + org_id = 123 + logs = StatsigProvider(org_id, "abcdefgh", request_timestamp="1739400185400").handle( + { + "data": [ + { + "timestamp": 1739400185198, + "eventName": "statsig::config_change", + "metadata": { + "type": "Gate", + "name": "gate1", + "action": "updated", + }, + } + ] + } + ) + + assert len(logs) == 1 + assert logs[0]["action"] == 2 + assert logs[0]["flag"] == "gate1" + assert logs[0]["organization_id"] == org_id + assert logs[0]["created_by"] is None + assert logs[0]["created_by_type"] is None + + +def test_handle_no_project_or_environments(): + org_id = 123 + logs = StatsigProvider(org_id, "abcdefgh", request_timestamp="1739400185400").handle( + { + "data": [ + { + "timestamp": 1739400185198, + "eventName": "statsig::config_change", + "metadata": { + "type": "Gate", + "name": "gate1", + "action": "updated", + }, + } + ] + } + ) + + assert len(logs) == 1 + assert logs[0]["action"] == 2 + assert logs[0]["flag"] == "gate1" + assert logs[0]["organization_id"] == org_id + + +def test_handle_additional_fields(): + org_id = 123 + logs = StatsigProvider(org_id, "abcdefgh", request_timestamp="1739400185400").handle( + { + "data": [ + { + "user": {"userID": "456"}, + "timestamp": 1739400185198, + "eventName": "statsig::config_change", + "metadata": { + "type": "Gate", + "name": "gate1", + "action": "updated", + "foo": "bar", + }, + # Additional fields (optional) + "value": "some value", + "statsigMetadata": {"something": 53}, + "timeUUID": "123e4567-e89b-12d3-a456-426614174000", + "unitID": "eureka", + } + ] + } + ) + + assert len(logs) == 1 + assert logs[0]["action"] == 2 + assert logs[0]["flag"] == "gate1" + assert logs[0]["organization_id"] == org_id + + +def test_handle_created_by_id(): + logs = StatsigProvider(123, "abcdefgh", request_timestamp="1739400185400").handle( + { + "data": [ + { + "user": {"userID": "456"}, + "timestamp": 1739400185198, + "eventName": "statsig::config_change", + "metadata": { + "type": "Gate", + "name": "gate1", + "action": "updated", + }, + } + ] + } + ) + assert len(logs) == 1 + assert logs[0]["created_by"] == "456" + assert logs[0]["created_by_type"] == 1 + + +def test_handle_created_by_id2(): + logs = StatsigProvider(123, "abcdefgh", request_timestamp="1739400185400").handle( + { + "data": [ + { + "userID": "456", + "timestamp": 1739400185198, + "eventName": "statsig::config_change", + "metadata": { + "type": "Gate", + "name": "gate1", + "action": "updated", + }, + } + ] + } + ) + assert len(logs) == 1 + assert logs[0]["created_by"] == "456" + assert logs[0]["created_by_type"] == 1 + + +def test_handle_created_by_name(): + logs = StatsigProvider(123, "abcdefgh", request_timestamp="1739400185400").handle( + { + "data": [ + { + "user": {"name": "johndoe"}, + "timestamp": 1739400185198, + "eventName": "statsig::config_change", + "metadata": { + "type": "Gate", + "name": "gate1", + "action": "updated", + }, + } + ] + } + ) + + assert len(logs) == 1 + assert logs[0]["created_by"] == "johndoe" + assert logs[0]["created_by_type"] == 2 + + +def test_handle_unsupported_events(): + logs = StatsigProvider(123, "abcdefgh", request_timestamp="1739400185400").handle( + { + "data": [ + { + "timestamp": 1739400185198, + "eventName": "statsig::gate_exposure", + "metadata": { + "type": "Gate", + "name": "gate1", + "action": "updated", + }, + }, + { + "timestamp": 1739400185199, + "eventName": "statsig::config_exposure", + "metadata": { + "type": "Gate", + "name": "gate1", + "action": "updated", + }, + }, + { + "timestamp": 1739400185200, + "eventName": "custom event", + "metadata": { + "type": "Gate", + "name": "gate1", + "action": "updated", + }, + }, + ] + } + ) + assert len(logs) == 0 + + +def test_handle_unsupported_config_changes(): + logs = StatsigProvider(123, "abcdefgh", request_timestamp="1739400185400").handle( + { + "data": [ + { + "timestamp": 1739400185198, + "eventName": "statsig::config_change", + "metadata": { + "type": "Experiment", + "name": "hello", + "action": "updated", + }, + }, + { + "timestamp": 1739400185199, + "eventName": "statsig::config_change", + "metadata": { + "type": "DynamicConfig", + "name": "world", + "action": "updated", + }, + }, + ] + } + ) + assert len(logs) == 0 + + +def test_handle_unsupported_action(): + logs = StatsigProvider(123, "abcdefgh", request_timestamp="1739400185400").handle( + { + "data": [ + { + "timestamp": 1739400185198, + "eventName": "statsig::config_change", + "metadata": { + "type": "Gate", + "name": "gate1", + "action": "kickflip", + }, + }, + ] + } + ) + assert len(logs) == 0 + + +def test_handle_empty_data(): + logs = StatsigProvider(123, "abcdefgh", request_timestamp="1739400185400").handle({"data": []}) + assert len(logs) == 0