Skip to content

feat(flags): Store options changes in the audit log #78622

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
101 changes: 7 additions & 94 deletions src/sentry/flags/endpoints/hooks.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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"]
]
85 changes: 85 additions & 0 deletions src/sentry/flags/providers.py
Original file line number Diff line number Diff line change
@@ -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
]
)
14 changes: 14 additions & 0 deletions src/sentry/options/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
45 changes: 45 additions & 0 deletions src/sentry/runner/commands/presenters/audit_log_presenter.py
Original file line number Diff line number Diff line change
@@ -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),
)
]
6 changes: 6 additions & 0 deletions src/sentry/runner/commands/presenters/presenterdelegator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading