diff --git a/src/sentry/flags/endpoints/hooks.py b/src/sentry/flags/endpoints/hooks.py index 16522242930f1f..0f71378062dbea 100644 --- a/src/sentry/flags/endpoints/hooks.py +++ b/src/sentry/flags/endpoints/hooks.py @@ -1,7 +1,3 @@ -import datetime -from typing import Any, TypedDict - -from rest_framework import serializers from rest_framework.request import Request from rest_framework.response import Response @@ -11,7 +7,12 @@ from sentry.api.base import Endpoint, region_silo_endpoint from sentry.api.bases.organization import OrganizationPermission from sentry.api.exceptions import ResourceDoesNotExist -from sentry.flags.models import ACTION_MAP, CREATED_BY_TYPE_MAP, FlagAuditLogModel +from sentry.flags.providers import ( + DeserializationError, + InvalidProvider, + handle_provider_event, + write, +) from sentry.models.organization import Organization from sentry.utils.sdk import bind_organization_context @@ -65,97 +66,9 @@ def convert_args( def post(self, request: Request, organization: Organization, provider: str) -> Response: try: - rows_data = handle_provider_event(provider, request.data, organization.id) - FlagAuditLogModel.objects.bulk_create(FlagAuditLogModel(**row) for row in rows_data) + write(handle_provider_event(provider, request.data, organization.id)) return Response(status=200) except InvalidProvider: raise ResourceDoesNotExist except DeserializationError as exc: return Response(exc.errors, status=400) - - -"""Provider definitions. - -Provider definitions are pure functions. They accept data and return data. Providers do not -initiate any IO operations. Instead they return commands in the form of the return type or -an exception. These commands inform the caller (the endpoint defintion) what IO must be -emitted to satisfy the request. This is done primarily to improve testability and test -performance but secondarily to allow easy extension of the endpoint without knowledge of -the underlying systems. -""" - - -class FlagAuditLogRow(TypedDict): - """A complete flag audit log row instance.""" - - action: int - created_at: datetime.datetime - created_by: str - created_by_type: int - flag: str - organization_id: int - tags: dict[str, Any] - - -class DeserializationError(Exception): - """The request body could not be deserialized.""" - - def __init__(self, errors): - self.errors = errors - - -class InvalidProvider(Exception): - """An unsupported provider type was specified.""" - - ... - - -def handle_provider_event( - provider: str, - request_data: dict[str, Any], - organization_id: int, -) -> list[FlagAuditLogRow]: - if provider == "flag-pole": - return handle_flag_pole_event(request_data, organization_id) - else: - raise InvalidProvider(provider) - - -"""Flag pole provider definition. - -If you are not Sentry you will not ever use this driver. Metadata provider by flag pole is -limited to what we can extract from the git repository on merge. -""" - - -class FlagPoleItemSerializer(serializers.Serializer): - action = serializers.ChoiceField(choices=("created", "updated"), required=True) - created_at = serializers.DateTimeField(required=True) - created_by = serializers.CharField(required=True) - flag = serializers.CharField(max_length=100, required=True) - tags = serializers.DictField(required=True) - - -class FlagPoleSerializer(serializers.Serializer): - data = FlagPoleItemSerializer(many=True, required=True) # type: ignore[assignment] - - -def handle_flag_pole_event( - request_data: dict[str, Any], organization_id: int -) -> list[FlagAuditLogRow]: - serializer = FlagPoleSerializer(data=request_data) - if not serializer.is_valid(): - raise DeserializationError(serializer.errors) - - return [ - dict( - action=ACTION_MAP[validated_item["action"]], - created_at=validated_item["created_at"], - created_by=validated_item["created_by"], - created_by_type=CREATED_BY_TYPE_MAP["email"], - flag=validated_item["flag"], - organization_id=organization_id, - tags=validated_item["tags"], - ) - for validated_item in serializer.validated_data["data"] - ] diff --git a/src/sentry/flags/providers.py b/src/sentry/flags/providers.py new file mode 100644 index 00000000000000..be51bb069e16fd --- /dev/null +++ b/src/sentry/flags/providers.py @@ -0,0 +1,85 @@ +import datetime +from typing import Any, TypedDict + +from sentry.flags.models import ACTION_MAP, CREATED_BY_TYPE_MAP, FlagAuditLogModel + + +def write(rows: list["FlagAuditLogRow"]) -> None: + FlagAuditLogModel.objects.bulk_create(FlagAuditLogModel(**row) for row in rows) + + +"""Provider definitions. + +Provider definitions are pure functions. They accept data and return data. Providers do not +initiate any IO operations. Instead they return commands in the form of the return type or +an exception. These commands inform the caller (the endpoint defintion) what IO must be +emitted to satisfy the request. This is done primarily to improve testability and test +performance but secondarily to allow easy extension of the endpoint without knowledge of +the underlying systems. +""" + + +class FlagAuditLogRow(TypedDict): + """A complete flag audit log row instance.""" + + action: int + created_at: datetime.datetime + created_by: str + created_by_type: int + flag: str + organization_id: int + tags: dict[str, Any] + + +class DeserializationError(Exception): + """The request body could not be deserialized.""" + + def __init__(self, errors): + self.errors = errors + + +class InvalidProvider(Exception): + """An unsupported provider type was specified.""" + + ... + + +def handle_provider_event( + provider: str, + request_data: dict[str, Any], + organization_id: int, +) -> list[FlagAuditLogRow]: + raise InvalidProvider(provider) + + +"""Internal flag-pole provider. + +Allows us to skip the HTTP endpoint. +""" + + +class FlagAuditLogItem(TypedDict): + """A simplified type which is easier to work with than the row definition.""" + + action: str + flag: str + created_at: datetime.datetime + created_by: str + tags: dict[str, str] + + +def handle_flag_pole_event_internal(items: list[FlagAuditLogItem], organization_id: int) -> None: + write( + [ + { + "action": ACTION_MAP[item["action"]], + "created_at": item["created_at"], + "created_by": item["created_by"], + "created_by_type": CREATED_BY_TYPE_MAP["name"], + "flag": item["flag"], + "organization_id": organization_id, + "tags": item["tags"], + } + for item in items + ] + ) diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py index 5acba417775549..6f9db447c93269 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -424,6 +424,20 @@ flags=FLAG_ALLOW_EMPTY | FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, ) +# Flag Options +register( + "flags:options-audit-log-is-enabled", + default=True, + flags=FLAG_ALLOW_EMPTY | FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, + type=Bool, +) +register( + "flags:options-audit-log-organization-id", + default=None, + flags=FLAG_ALLOW_EMPTY | FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, + type=Int, +) + # Replay Options # # Replay storage backend configuration (only applicable if the direct-storage driver is used) diff --git a/src/sentry/runner/commands/presenters/audit_log_presenter.py b/src/sentry/runner/commands/presenters/audit_log_presenter.py new file mode 100644 index 00000000000000..e97623f8997526 --- /dev/null +++ b/src/sentry/runner/commands/presenters/audit_log_presenter.py @@ -0,0 +1,45 @@ +import itertools +import logging +from datetime import datetime, timezone + +from sentry import options +from sentry.flags.providers import FlagAuditLogItem, handle_flag_pole_event_internal +from sentry.runner.commands.presenters.webhookpresenter import WebhookPresenter + +logger = logging.getLogger() + + +class AuditLogPresenter(WebhookPresenter): + @staticmethod + def is_webhook_enabled() -> bool: + return ( + options.get("flags:options-audit-log-is-enabled") is True + and options.get("flags:options-audit-log-organization-id") is not None + ) + + def flush(self) -> None: + if not self.is_webhook_enabled(): + logger.warning("Options audit log webhook is disabled.") + return None + + items = self._create_audit_log_items() + handle_flag_pole_event_internal( + items, organization_id=options.get("flags:options-audit-log-organization-id") + ) + + def _create_audit_log_items(self) -> list[FlagAuditLogItem]: + return [ + { + "action": action, + "created_at": datetime.now(tz=timezone.utc), + "created_by": "internal", + "flag": flag, + "tags": tags, + } + for flag, action, tags in itertools.chain( + ((flag, "created", {"value": v}) for flag, v in self.set_options), + ((flag, "deleted", {}) for flag in self.unset_options), + ((flag, "updated", {"value": v}) for flag, _, v in self.updated_options), + ((flag, "updated", {}) for flag, _ in self.drifted_options), + ) + ] diff --git a/src/sentry/runner/commands/presenters/presenterdelegator.py b/src/sentry/runner/commands/presenters/presenterdelegator.py index 18161346b4b905..d5bacd00656e85 100644 --- a/src/sentry/runner/commands/presenters/presenterdelegator.py +++ b/src/sentry/runner/commands/presenters/presenterdelegator.py @@ -6,16 +6,22 @@ class PresenterDelegator: def __init__(self, source: str) -> None: + from sentry.runner.commands.presenters.audit_log_presenter import AuditLogPresenter + self._consolepresenter = ConsolePresenter() self._slackpresenter = None if WebhookPresenter.is_webhook_enabled(): self._slackpresenter = WebhookPresenter(source) + if AuditLogPresenter.is_webhook_enabled(): + self._auditlogpresenter = AuditLogPresenter(source) def __getattr__(self, attr: str) -> Any: def wrapper(*args: Any, **kwargs: Any) -> None: getattr(self._consolepresenter, attr)(*args, **kwargs) if self._slackpresenter: getattr(self._slackpresenter, attr)(*args, **kwargs) + if self._auditlogpresenter: + getattr(self._auditlogpresenter, attr)(*args, **kwargs) return wrapper diff --git a/tests/sentry/flags/endpoints/test_hooks.py b/tests/sentry/flags/endpoints/test_hooks.py index 039cfcb9415a0b..e9c61842f34567 100644 --- a/tests/sentry/flags/endpoints/test_hooks.py +++ b/tests/sentry/flags/endpoints/test_hooks.py @@ -1,94 +1,16 @@ -from datetime import datetime, timezone - -import pytest from django.urls import reverse -from sentry.flags.endpoints.hooks import ( - DeserializationError, - InvalidProvider, - handle_flag_pole_event, - handle_provider_event, -) -from sentry.flags.models import ACTION_MAP, CREATED_BY_TYPE_MAP, FlagAuditLogModel from sentry.testutils.cases import APITestCase from sentry.utils.security.orgauthtoken_token import hash_token -def test_handle_provider_event(): - result = handle_provider_event( - "flag-pole", - { - "data": [ - { - "action": "created", - "flag": "test", - "created_at": "2024-01-01T00:00:00", - "created_by": "colton.allen@sentry.io", - "tags": {"commit_sha": "123"}, - } - ] - }, - 1, - ) - - assert result[0]["action"] == ACTION_MAP["created"] - assert result[0]["flag"] == "test" - assert result[0]["created_at"] == datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc) - assert result[0]["created_by"] == "colton.allen@sentry.io" - assert result[0]["created_by_type"] == CREATED_BY_TYPE_MAP["email"] - assert result[0]["organization_id"] == 1 - assert result[0]["tags"] == {"commit_sha": "123"} - - -def test_handle_provider_event_invalid_provider(): - with pytest.raises(InvalidProvider): - handle_provider_event("other", {}, 1) - - -def test_handle_flag_pole_event(): - result = handle_flag_pole_event( - { - "data": [ - { - "action": "created", - "flag": "test", - "created_at": "2024-01-01T00:00:00", - "created_by": "colton.allen@sentry.io", - "tags": {"commit_sha": "123"}, - } - ] - }, - 1, - ) - - assert result[0]["action"] == ACTION_MAP["created"] - assert result[0]["flag"] == "test" - assert result[0]["created_at"] == datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc) - assert result[0]["created_by"] == "colton.allen@sentry.io" - assert result[0]["created_by_type"] == CREATED_BY_TYPE_MAP["email"] - assert result[0]["organization_id"] == 1 - assert result[0]["tags"] == {"commit_sha": "123"} - - -def test_handle_flag_pole_event_bad_request(): - try: - handle_flag_pole_event({"data": [{}]}, 1) - except DeserializationError as exc: - assert exc.errors["data"][0]["action"][0].code == "required" - assert exc.errors["data"][0]["flag"][0].code == "required" - assert exc.errors["data"][0]["created_at"][0].code == "required" - assert exc.errors["data"][0]["created_by"][0].code == "required" - assert exc.errors["data"][0]["tags"][0].code == "required" - else: - assert False, "Expected deserialization error" - - class OrganizationFlagsHooksEndpointTestCase(APITestCase): endpoint = "sentry-api-0-organization-flag-hooks" + provider = "test" def setUp(self): super().setUp() - self.url = reverse(self.endpoint, args=(self.organization.slug, "flag-pole")) + self.url = reverse(self.endpoint, args=(self.organization.slug, self.provider)) def test_post(self): token = "sntrys_abc123_xyz" @@ -102,34 +24,3 @@ def test_post(self): ) self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") - - response = self.client.post( - self.url, - data={ - "data": [ - { - "action": "created", - "flag": "test", - "created_at": "2024-01-01T00:00:00", - "created_by": "colton.allen@sentry.io", - "tags": {"commit_sha": "123"}, - } - ] - }, - ) - assert response.status_code == 200 - - assert FlagAuditLogModel.objects.count() == 1 - flag = FlagAuditLogModel.objects.first() - assert flag is not None - assert flag.action == ACTION_MAP["created"] - assert flag.flag == "test" - assert flag.created_at == datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc) - assert flag.created_by == "colton.allen@sentry.io" - assert flag.created_by_type == CREATED_BY_TYPE_MAP["email"] - assert flag.organization_id == self.organization.id - assert flag.tags == {"commit_sha": "123"} - - def test_post_unauthorized(self): - response = self.client.post(self.url, data={}) - assert response.status_code == 401 diff --git a/tests/sentry/flags/test_audit_log_presenter.py b/tests/sentry/flags/test_audit_log_presenter.py new file mode 100644 index 00000000000000..cbd9a6501c3466 --- /dev/null +++ b/tests/sentry/flags/test_audit_log_presenter.py @@ -0,0 +1,55 @@ +from sentry.flags.models import ACTION_MAP, CREATED_BY_TYPE_MAP, FlagAuditLogModel +from sentry.runner.commands.presenters.audit_log_presenter import AuditLogPresenter +from sentry.testutils.cases import APITestCase + + +def test_audit_log_item_generation(): + presenter = AuditLogPresenter("") + presenter.set("a", True) + presenter.unset("b") + presenter.update("c", True, False) + presenter.drift("d", False) + + items = presenter._create_audit_log_items() + assert len(items) == 4 + + assert items[0]["action"] == "created" + assert items[0]["flag"] == "a" + assert items[0]["tags"] == {"value": True} + + assert items[1]["action"] == "deleted" + assert items[1]["flag"] == "b" + assert items[1]["tags"] == {} + + assert items[2]["action"] == "updated" + assert items[2]["flag"] == "c" + assert items[2]["tags"] == {"value": False} + + assert items[3]["action"] == "updated" + assert items[3]["flag"] == "d" + assert items[3]["tags"] == {} + + +class AuditLogPresenterFunctionalTestCase(APITestCase): + endpoint = "sentry-api-0-organization-flag-hooks" + + def test_audit_log_presenter_flush(self): + with self.options( + { + "flags:options-audit-log-is-enabled": True, + "flags:options-audit-log-organization-id": self.organization.id, + } + ): + presenter = AuditLogPresenter("") + presenter.set("a", True) + presenter.flush() + + assert FlagAuditLogModel.objects.count() == 1 + flag = FlagAuditLogModel.objects.first() + assert flag is not None + assert flag.action == ACTION_MAP["created"] + assert flag.flag == "a" + assert flag.created_by == "internal" + assert flag.created_by_type == CREATED_BY_TYPE_MAP["name"] + assert flag.organization_id == self.organization.id + assert flag.tags == {"value": True}