Skip to content

Commit 5f5d2ed

Browse files
authored
feat(flags): Store options changes in the audit log (#78622)
When an option is added, updated, or removed record that action in the audit log.
1 parent 4e35128 commit 5f5d2ed

File tree

7 files changed

+214
-205
lines changed

7 files changed

+214
-205
lines changed

src/sentry/flags/endpoints/hooks.py

Lines changed: 7 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,3 @@
1-
import datetime
2-
from typing import Any, TypedDict
3-
4-
from rest_framework import serializers
51
from rest_framework.request import Request
62
from rest_framework.response import Response
73

@@ -11,7 +7,12 @@
117
from sentry.api.base import Endpoint, region_silo_endpoint
128
from sentry.api.bases.organization import OrganizationPermission
139
from sentry.api.exceptions import ResourceDoesNotExist
14-
from sentry.flags.models import ACTION_MAP, CREATED_BY_TYPE_MAP, FlagAuditLogModel
10+
from sentry.flags.providers import (
11+
DeserializationError,
12+
InvalidProvider,
13+
handle_provider_event,
14+
write,
15+
)
1516
from sentry.models.organization import Organization
1617
from sentry.utils.sdk import bind_organization_context
1718

@@ -65,97 +66,9 @@ def convert_args(
6566

6667
def post(self, request: Request, organization: Organization, provider: str) -> Response:
6768
try:
68-
rows_data = handle_provider_event(provider, request.data, organization.id)
69-
FlagAuditLogModel.objects.bulk_create(FlagAuditLogModel(**row) for row in rows_data)
69+
write(handle_provider_event(provider, request.data, organization.id))
7070
return Response(status=200)
7171
except InvalidProvider:
7272
raise ResourceDoesNotExist
7373
except DeserializationError as exc:
7474
return Response(exc.errors, status=400)
75-
76-
77-
"""Provider definitions.
78-
79-
Provider definitions are pure functions. They accept data and return data. Providers do not
80-
initiate any IO operations. Instead they return commands in the form of the return type or
81-
an exception. These commands inform the caller (the endpoint defintion) what IO must be
82-
emitted to satisfy the request. This is done primarily to improve testability and test
83-
performance but secondarily to allow easy extension of the endpoint without knowledge of
84-
the underlying systems.
85-
"""
86-
87-
88-
class FlagAuditLogRow(TypedDict):
89-
"""A complete flag audit log row instance."""
90-
91-
action: int
92-
created_at: datetime.datetime
93-
created_by: str
94-
created_by_type: int
95-
flag: str
96-
organization_id: int
97-
tags: dict[str, Any]
98-
99-
100-
class DeserializationError(Exception):
101-
"""The request body could not be deserialized."""
102-
103-
def __init__(self, errors):
104-
self.errors = errors
105-
106-
107-
class InvalidProvider(Exception):
108-
"""An unsupported provider type was specified."""
109-
110-
...
111-
112-
113-
def handle_provider_event(
114-
provider: str,
115-
request_data: dict[str, Any],
116-
organization_id: int,
117-
) -> list[FlagAuditLogRow]:
118-
if provider == "flag-pole":
119-
return handle_flag_pole_event(request_data, organization_id)
120-
else:
121-
raise InvalidProvider(provider)
122-
123-
124-
"""Flag pole provider definition.
125-
126-
If you are not Sentry you will not ever use this driver. Metadata provider by flag pole is
127-
limited to what we can extract from the git repository on merge.
128-
"""
129-
130-
131-
class FlagPoleItemSerializer(serializers.Serializer):
132-
action = serializers.ChoiceField(choices=("created", "updated"), required=True)
133-
created_at = serializers.DateTimeField(required=True)
134-
created_by = serializers.CharField(required=True)
135-
flag = serializers.CharField(max_length=100, required=True)
136-
tags = serializers.DictField(required=True)
137-
138-
139-
class FlagPoleSerializer(serializers.Serializer):
140-
data = FlagPoleItemSerializer(many=True, required=True) # type: ignore[assignment]
141-
142-
143-
def handle_flag_pole_event(
144-
request_data: dict[str, Any], organization_id: int
145-
) -> list[FlagAuditLogRow]:
146-
serializer = FlagPoleSerializer(data=request_data)
147-
if not serializer.is_valid():
148-
raise DeserializationError(serializer.errors)
149-
150-
return [
151-
dict(
152-
action=ACTION_MAP[validated_item["action"]],
153-
created_at=validated_item["created_at"],
154-
created_by=validated_item["created_by"],
155-
created_by_type=CREATED_BY_TYPE_MAP["email"],
156-
flag=validated_item["flag"],
157-
organization_id=organization_id,
158-
tags=validated_item["tags"],
159-
)
160-
for validated_item in serializer.validated_data["data"]
161-
]

src/sentry/flags/providers.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import datetime
2+
from typing import Any, TypedDict
3+
4+
from sentry.flags.models import ACTION_MAP, CREATED_BY_TYPE_MAP, FlagAuditLogModel
5+
6+
7+
def write(rows: list["FlagAuditLogRow"]) -> None:
8+
FlagAuditLogModel.objects.bulk_create(FlagAuditLogModel(**row) for row in rows)
9+
10+
11+
"""Provider definitions.
12+
13+
Provider definitions are pure functions. They accept data and return data. Providers do not
14+
initiate any IO operations. Instead they return commands in the form of the return type or
15+
an exception. These commands inform the caller (the endpoint defintion) what IO must be
16+
emitted to satisfy the request. This is done primarily to improve testability and test
17+
performance but secondarily to allow easy extension of the endpoint without knowledge of
18+
the underlying systems.
19+
"""
20+
21+
22+
class FlagAuditLogRow(TypedDict):
23+
"""A complete flag audit log row instance."""
24+
25+
action: int
26+
created_at: datetime.datetime
27+
created_by: str
28+
created_by_type: int
29+
flag: str
30+
organization_id: int
31+
tags: dict[str, Any]
32+
33+
34+
class DeserializationError(Exception):
35+
"""The request body could not be deserialized."""
36+
37+
def __init__(self, errors):
38+
self.errors = errors
39+
40+
41+
class InvalidProvider(Exception):
42+
"""An unsupported provider type was specified."""
43+
44+
...
45+
46+
47+
def handle_provider_event(
48+
provider: str,
49+
request_data: dict[str, Any],
50+
organization_id: int,
51+
) -> list[FlagAuditLogRow]:
52+
raise InvalidProvider(provider)
53+
54+
55+
"""Internal flag-pole provider.
56+
57+
Allows us to skip the HTTP endpoint.
58+
"""
59+
60+
61+
class FlagAuditLogItem(TypedDict):
62+
"""A simplified type which is easier to work with than the row definition."""
63+
64+
action: str
65+
flag: str
66+
created_at: datetime.datetime
67+
created_by: str
68+
tags: dict[str, str]
69+
70+
71+
def handle_flag_pole_event_internal(items: list[FlagAuditLogItem], organization_id: int) -> None:
72+
write(
73+
[
74+
{
75+
"action": ACTION_MAP[item["action"]],
76+
"created_at": item["created_at"],
77+
"created_by": item["created_by"],
78+
"created_by_type": CREATED_BY_TYPE_MAP["name"],
79+
"flag": item["flag"],
80+
"organization_id": organization_id,
81+
"tags": item["tags"],
82+
}
83+
for item in items
84+
]
85+
)

src/sentry/options/defaults.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,20 @@
424424
flags=FLAG_ALLOW_EMPTY | FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE,
425425
)
426426

427+
# Flag Options
428+
register(
429+
"flags:options-audit-log-is-enabled",
430+
default=True,
431+
flags=FLAG_ALLOW_EMPTY | FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE,
432+
type=Bool,
433+
)
434+
register(
435+
"flags:options-audit-log-organization-id",
436+
default=None,
437+
flags=FLAG_ALLOW_EMPTY | FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE,
438+
type=Int,
439+
)
440+
427441
# Replay Options
428442
#
429443
# Replay storage backend configuration (only applicable if the direct-storage driver is used)
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import itertools
2+
import logging
3+
from datetime import datetime, timezone
4+
5+
from sentry import options
6+
from sentry.flags.providers import FlagAuditLogItem, handle_flag_pole_event_internal
7+
from sentry.runner.commands.presenters.webhookpresenter import WebhookPresenter
8+
9+
logger = logging.getLogger()
10+
11+
12+
class AuditLogPresenter(WebhookPresenter):
13+
@staticmethod
14+
def is_webhook_enabled() -> bool:
15+
return (
16+
options.get("flags:options-audit-log-is-enabled") is True
17+
and options.get("flags:options-audit-log-organization-id") is not None
18+
)
19+
20+
def flush(self) -> None:
21+
if not self.is_webhook_enabled():
22+
logger.warning("Options audit log webhook is disabled.")
23+
return None
24+
25+
items = self._create_audit_log_items()
26+
handle_flag_pole_event_internal(
27+
items, organization_id=options.get("flags:options-audit-log-organization-id")
28+
)
29+
30+
def _create_audit_log_items(self) -> list[FlagAuditLogItem]:
31+
return [
32+
{
33+
"action": action,
34+
"created_at": datetime.now(tz=timezone.utc),
35+
"created_by": "internal",
36+
"flag": flag,
37+
"tags": tags,
38+
}
39+
for flag, action, tags in itertools.chain(
40+
((flag, "created", {"value": v}) for flag, v in self.set_options),
41+
((flag, "deleted", {}) for flag in self.unset_options),
42+
((flag, "updated", {"value": v}) for flag, _, v in self.updated_options),
43+
((flag, "updated", {}) for flag, _ in self.drifted_options),
44+
)
45+
]

src/sentry/runner/commands/presenters/presenterdelegator.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,22 @@
66

77
class PresenterDelegator:
88
def __init__(self, source: str) -> None:
9+
from sentry.runner.commands.presenters.audit_log_presenter import AuditLogPresenter
10+
911
self._consolepresenter = ConsolePresenter()
1012

1113
self._slackpresenter = None
1214
if WebhookPresenter.is_webhook_enabled():
1315
self._slackpresenter = WebhookPresenter(source)
16+
if AuditLogPresenter.is_webhook_enabled():
17+
self._auditlogpresenter = AuditLogPresenter(source)
1418

1519
def __getattr__(self, attr: str) -> Any:
1620
def wrapper(*args: Any, **kwargs: Any) -> None:
1721
getattr(self._consolepresenter, attr)(*args, **kwargs)
1822
if self._slackpresenter:
1923
getattr(self._slackpresenter, attr)(*args, **kwargs)
24+
if self._auditlogpresenter:
25+
getattr(self._auditlogpresenter, attr)(*args, **kwargs)
2026

2127
return wrapper

0 commit comments

Comments
 (0)