Skip to content
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

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

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
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"],
cmanallen marked this conversation as resolved.
Show resolved Hide resolved
"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),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what does a drifted option mean?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Internal lingo. Basically the value was updated but the existing value in the db was unexpected.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we don't care about the new updated value in this case?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We still do. The value is changed in the db. Its just that the old value was not what we expected (someone manually changed it).

Copy link
Member Author

@cmanallen cmanallen Oct 4, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I see the tags. The drifted options don't contain the new value (just the old...) which is why it was omitted. This is a legacy event any way. I could probably remove it.

)
]
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