Skip to content

Commit 3535a11

Browse files
Add a new security logger for app activities (#102488)
<!-- Describe your PR here. --> <!-- Sentry employees and contractors can delete or ignore the following. --> ### Legal Boilerplate Look, I get it. The entity doing business as "Sentry" was incorporated in the State of Delaware in 2015 as Functional Software, Inc. and is gonna need some rights from me in order to utilize my contributions in this here PR. So here's the deal: I retain all rights, title and interest in and to my contributions, and by keeping this boilerplate intact I confirm that Sentry can use, modify, copy, and redistribute my contributions, under Sentry's choice of terms.
1 parent ca2b725 commit 3535a11

File tree

2 files changed

+226
-0
lines changed

2 files changed

+226
-0
lines changed

src/sentry/security/utils.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,15 @@
88
from django.contrib.auth.models import AnonymousUser
99
from django.utils import timezone
1010

11+
from sentry.organizations.services.organization.model import RpcOrganization
12+
from sentry.sentry_apps.services.app.model import RpcSentryApp
1113
from sentry.users.services.user.model import RpcUser
1214

1315
from .emails import generate_security_email
1416

1517
if TYPE_CHECKING:
18+
from sentry.models.organization import Organization
19+
from sentry.sentry_apps.models import SentryApp
1620
from sentry.users.models.user import User
1721

1822

@@ -51,3 +55,24 @@ def capture_security_activity(
5155
current_datetime=current_datetime,
5256
)
5357
msg.send_async([account.email])
58+
59+
60+
def capture_security_app_activity(
61+
organization: Organization | RpcOrganization,
62+
sentry_app: SentryApp | RpcSentryApp,
63+
activity_type: str,
64+
ip_address: str,
65+
context: Mapping[str, Any] | None = None,
66+
) -> None:
67+
logger_context = {
68+
"ip_address": ip_address,
69+
"organization_id": organization.id,
70+
"sentry_app_id": sentry_app.id,
71+
}
72+
73+
# Add the installation_id if it exists.
74+
if context:
75+
if "installation_id" in context:
76+
logger_context["installation_id"] = context["installation_id"]
77+
78+
logger.info("audit.sentry_app.%s", activity_type, extra=logger_context)
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
from unittest.mock import MagicMock, patch
2+
3+
from sentry.security.utils import capture_security_app_activity
4+
from sentry.sentry_apps.logic import SentryAppCreator
5+
from sentry.sentry_apps.services.app import app_service
6+
from sentry.silo.base import SiloMode
7+
from sentry.testutils.factories import Factories
8+
from sentry.testutils.pytest.fixtures import django_db_all
9+
from sentry.testutils.silo import all_silo_test, assume_test_silo_mode
10+
11+
12+
@all_silo_test
13+
@django_db_all
14+
@patch("sentry.security.utils.logger")
15+
def test_logs_app_activity(
16+
mock_logger: MagicMock,
17+
) -> None:
18+
# Create a user. This has to be done not in the control mode.
19+
with assume_test_silo_mode(SiloMode.CONTROL):
20+
user = Factories.create_user(email="[email protected]")
21+
22+
# Create an organization. This has to be done not in the control mode.
23+
# Turn the organization into an RPC organization.
24+
with assume_test_silo_mode(SiloMode.REGION):
25+
organization = Factories.create_organization(owner=user)
26+
27+
# Create a Sentry App using the creator. This needs to be run in control mode.
28+
with assume_test_silo_mode(SiloMode.CONTROL):
29+
creator = SentryAppCreator(
30+
name="Test Security App",
31+
author="Sentry",
32+
organization_id=organization.id,
33+
scopes=["project:read", "project:write"],
34+
webhook_url="http://example.com/webhook",
35+
is_internal=False,
36+
)
37+
sentry_app_orm = creator.run(user=user)
38+
39+
# Get RPC version for cross-silo compatibility
40+
sentry_app = app_service.get_sentry_app_by_slug(slug=sentry_app_orm.slug)
41+
assert sentry_app is not None
42+
43+
ip_address = "127.0.0.1"
44+
45+
capture_security_app_activity(
46+
organization=organization,
47+
sentry_app=sentry_app,
48+
activity_type="app-installed",
49+
ip_address=ip_address,
50+
)
51+
52+
mock_logger.info.assert_called_once()
53+
call_args = mock_logger.info.call_args
54+
55+
assert call_args[0][0] == "audit.sentry_app.%s"
56+
assert call_args[0][1] == "app-installed"
57+
58+
extra = call_args[1]["extra"]
59+
assert extra["ip_address"] == ip_address
60+
assert extra["organization_id"] == organization.id
61+
assert extra["sentry_app_id"] == sentry_app.id
62+
63+
64+
@all_silo_test
65+
@django_db_all
66+
@patch("sentry.security.utils.logger")
67+
def test_logs_with_installation_context(mock_logger: MagicMock) -> None:
68+
"""Test that installation_id is included in logs when present in context."""
69+
with assume_test_silo_mode(SiloMode.CONTROL):
70+
user = Factories.create_user(email="[email protected]")
71+
72+
with assume_test_silo_mode(SiloMode.REGION):
73+
organization = Factories.create_organization(owner=user)
74+
75+
with assume_test_silo_mode(SiloMode.CONTROL):
76+
creator = SentryAppCreator(
77+
name="Test Security App",
78+
author="Sentry",
79+
organization_id=organization.id,
80+
scopes=["project:read", "project:write"],
81+
webhook_url="http://example.com/webhook",
82+
is_internal=False,
83+
)
84+
sentry_app_orm = creator.run(user=user)
85+
86+
sentry_app = app_service.get_sentry_app_by_slug(slug=sentry_app_orm.slug)
87+
assert sentry_app is not None
88+
89+
ip_address = "192.168.1.1"
90+
installation_id = 12345
91+
92+
capture_security_app_activity(
93+
organization=organization,
94+
sentry_app=sentry_app,
95+
activity_type="app-token-created",
96+
ip_address=ip_address,
97+
context={"installation_id": installation_id},
98+
)
99+
100+
mock_logger.info.assert_called_once()
101+
extra = mock_logger.info.call_args[1]["extra"]
102+
103+
assert extra["installation_id"] == installation_id
104+
105+
106+
@all_silo_test
107+
@django_db_all
108+
@patch("sentry.security.utils.logger")
109+
def test_handles_different_activity_types(mock_logger: MagicMock) -> None:
110+
"""Test that various activity types are logged."""
111+
with assume_test_silo_mode(SiloMode.CONTROL):
112+
user = Factories.create_user(email="[email protected]")
113+
114+
with assume_test_silo_mode(SiloMode.REGION):
115+
organization = Factories.create_organization(owner=user)
116+
117+
with assume_test_silo_mode(SiloMode.CONTROL):
118+
creator = SentryAppCreator(
119+
name="Test Security App",
120+
author="Sentry",
121+
organization_id=organization.id,
122+
scopes=["project:read", "project:write"],
123+
webhook_url="http://example.com/webhook",
124+
is_internal=False,
125+
)
126+
sentry_app_orm = creator.run(user=user)
127+
128+
sentry_app = app_service.get_sentry_app_by_slug(slug=sentry_app_orm.slug)
129+
assert sentry_app is not None
130+
131+
activity_types = [
132+
"app-installed",
133+
"app-uninstalled",
134+
"app-token-created",
135+
"app-permission-changed",
136+
"app-updated",
137+
]
138+
139+
for activity_type in activity_types:
140+
mock_logger.reset_mock()
141+
142+
capture_security_app_activity(
143+
organization=organization,
144+
sentry_app=sentry_app,
145+
activity_type=activity_type,
146+
ip_address="127.0.0.1",
147+
)
148+
149+
mock_logger.info.assert_called_once()
150+
call_args = mock_logger.info.call_args
151+
assert call_args[0][1] == activity_type
152+
153+
154+
@all_silo_test
155+
@django_db_all
156+
@patch("sentry.security.utils.logger")
157+
def test_context_with_multiple_fields(mock_logger: MagicMock) -> None:
158+
"""Test that context with multiple fields is handled correctly."""
159+
with assume_test_silo_mode(SiloMode.CONTROL):
160+
user = Factories.create_user(email="[email protected]")
161+
162+
with assume_test_silo_mode(SiloMode.REGION):
163+
organization = Factories.create_organization(owner=user)
164+
165+
with assume_test_silo_mode(SiloMode.CONTROL):
166+
creator = SentryAppCreator(
167+
name="Test Security App",
168+
author="Sentry",
169+
organization_id=organization.id,
170+
scopes=["project:read", "project:write"],
171+
webhook_url="http://example.com/webhook",
172+
is_internal=False,
173+
)
174+
sentry_app_orm = creator.run(user=user)
175+
176+
sentry_app = app_service.get_sentry_app_by_slug(slug=sentry_app_orm.slug)
177+
assert sentry_app is not None
178+
179+
ip_address = "192.168.100.1"
180+
context = {
181+
"installation_id": 99999,
182+
"additional_field": "some_value",
183+
"another_field": 123,
184+
}
185+
186+
capture_security_app_activity(
187+
organization=organization,
188+
sentry_app=sentry_app,
189+
activity_type="app-configured",
190+
ip_address=ip_address,
191+
context=context,
192+
)
193+
194+
mock_logger.info.assert_called_once()
195+
extra = mock_logger.info.call_args[1]["extra"]
196+
197+
# Only installation_id should be extracted to logger_context
198+
assert extra["installation_id"] == 99999
199+
# Other context fields should not be in logger_context
200+
assert "additional_field" not in extra
201+
assert "another_field" not in extra

0 commit comments

Comments
 (0)