Skip to content

Commit 7af940e

Browse files
committed
feat(repos): Add audit logs when auto syncing repositories
Add in audit logs whenever we automatically add, update or disable a repository via webhooks and periodic syncing
1 parent a342e00 commit 7af940e

File tree

8 files changed

+175
-7
lines changed

8 files changed

+175
-7
lines changed

src/sentry/audit_log/events.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,3 +384,38 @@ def render(self, audit_log_entry: AuditLogEntry) -> str:
384384
return "updated repository settings for {repository_count} repositories".format(
385385
repository_count=data.get("repository_count", 0),
386386
)
387+
388+
389+
def _render_repo_event(action: str, audit_log_entry: AuditLogEntry) -> str:
390+
data = audit_log_entry.data
391+
actor = audit_log_entry.actor_label or "unknown"
392+
repo_name = data.get("repo_name", "unknown")
393+
source = data.get("source", "")
394+
msg = f"{actor} {action} repository {repo_name}"
395+
if source:
396+
msg += f" (via {source})"
397+
return msg
398+
399+
400+
class RepoAddedAuditLogEvent(AuditLogEvent):
401+
def __init__(self) -> None:
402+
super().__init__(event_id=1161, name="REPO_ADDED", api_name="repo.added")
403+
404+
def render(self, audit_log_entry: AuditLogEntry) -> str:
405+
return _render_repo_event("added", audit_log_entry)
406+
407+
408+
class RepoDisabledAuditLogEvent(AuditLogEvent):
409+
def __init__(self) -> None:
410+
super().__init__(event_id=1162, name="REPO_DISABLED", api_name="repo.disabled")
411+
412+
def render(self, audit_log_entry: AuditLogEntry) -> str:
413+
return _render_repo_event("disabled", audit_log_entry)
414+
415+
416+
class RepoEnabledAuditLogEvent(AuditLogEvent):
417+
def __init__(self) -> None:
418+
super().__init__(event_id=1163, name="REPO_ENABLED", api_name="repo.enabled")
419+
420+
def render(self, audit_log_entry: AuditLogEntry) -> str:
421+
return _render_repo_event("enabled", audit_log_entry)

src/sentry/audit_log/register.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -695,3 +695,6 @@
695695
template="updated autofix automation settings for {project_count} projects",
696696
)
697697
)
698+
default_manager.add(events.RepoAddedAuditLogEvent())
699+
default_manager.add(events.RepoDisabledAuditLogEvent())
700+
default_manager.add(events.RepoEnabledAuditLogEvent())

src/sentry/integrations/github/tasks/sync_repos.py

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
SCMIntegrationInteractionEvent,
2222
SCMIntegrationInteractionType,
2323
)
24+
from sentry.integrations.source_code_management.repo_audit import log_repo_change
2425
from sentry.organizations.services.organization import organization_service
2526
from sentry.plugins.providers.integration_repository import (
2627
RepoExistsError,
@@ -127,8 +128,9 @@ def sync_repos_for_org(organization_integration_id: int) -> None:
127128
r for r in all_repos if r.status == ObjectStatus.DISABLED and r.external_id
128129
]
129130

130-
sentry_active_ids = {r.external_id for r in active_repos}
131-
sentry_disabled_ids = {r.external_id for r in disabled_repos}
131+
# external_id is guaranteed non-None by the filter above
132+
sentry_active_ids: set[str] = {r.external_id for r in active_repos} # type: ignore[misc]
133+
sentry_disabled_ids: set[str] = {r.external_id for r in disabled_repos} # type: ignore[misc]
132134

133135
new_ids = github_external_ids - sentry_active_ids - sentry_disabled_ids
134136
removed_ids = sentry_active_ids - github_external_ids
@@ -171,6 +173,8 @@ def sync_repos_for_org(organization_integration_id: int) -> None:
171173
if dry_run:
172174
return
173175

176+
repo_by_external_id = {r.external_id: r for r in active_repos + disabled_repos}
177+
174178
if new_ids:
175179
integration_repo_provider = get_integration_repository_provider(integration)
176180
repo_configs = [
@@ -179,13 +183,23 @@ def sync_repos_for_org(organization_integration_id: int) -> None:
179183
if str(repo["id"]) in new_ids
180184
]
181185
if repo_configs:
186+
created_repos = []
182187
try:
183-
integration_repo_provider.create_repositories(
188+
created_repos = integration_repo_provider.create_repositories(
184189
configs=repo_configs, organization=rpc_org
185190
)
186191
except RepoExistsError:
187192
pass
188193

194+
for repo in created_repos:
195+
log_repo_change(
196+
event_name="REPO_ADDED",
197+
organization_id=organization_id,
198+
repo=repo,
199+
source="repository sync",
200+
provider=integration.provider,
201+
)
202+
189203
if removed_ids:
190204
repository_service.disable_repositories_by_external_ids(
191205
organization_id=organization_id,
@@ -194,13 +208,31 @@ def sync_repos_for_org(organization_integration_id: int) -> None:
194208
external_ids=list(removed_ids),
195209
)
196210

211+
for eid in removed_ids:
212+
repo = repo_by_external_id.get(eid)
213+
if repo:
214+
log_repo_change(
215+
event_name="REPO_DISABLED",
216+
organization_id=organization_id,
217+
repo=repo,
218+
source="repository sync",
219+
provider=integration.provider,
220+
)
221+
197222
if restored_ids:
198223
for repo in disabled_repos:
199224
if repo.external_id in restored_ids:
200225
repo.status = ObjectStatus.ACTIVE
201226
repository_service.update_repository(
202227
organization_id=organization_id, update=repo
203228
)
229+
log_repo_change(
230+
event_name="REPO_ENABLED",
231+
organization_id=organization_id,
232+
repo=repo,
233+
source="repository sync",
234+
provider=integration.provider,
235+
)
204236

205237

206238
@instrumented_task(

src/sentry/integrations/github/tasks/sync_repos_on_install_change.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
SCMIntegrationInteractionEvent,
1414
SCMIntegrationInteractionType,
1515
)
16+
from sentry.integrations.source_code_management.repo_audit import log_repo_change
1617
from sentry.organizations.services.organization import organization_service
1718
from sentry.organizations.services.organization.model import RpcOrganization
1819
from sentry.plugins.providers.integration_repository import (
@@ -119,18 +120,48 @@ def _sync_repos_for_org(
119120
continue
120121

121122
if repo_configs:
123+
created_repos = []
122124
try:
123-
integration_repo_provider.create_repositories(
125+
created_repos = integration_repo_provider.create_repositories(
124126
configs=repo_configs, organization=rpc_org
125127
)
126128
except RepoExistsError:
127129
pass
128130

131+
for created_repo in created_repos:
132+
log_repo_change(
133+
event_name="REPO_ADDED",
134+
organization_id=rpc_org.id,
135+
repo=created_repo,
136+
source="GitHub webhook",
137+
provider=integration.provider,
138+
)
139+
129140
if repos_removed:
141+
# Look up repos before disabling to get their IDs and names
130142
external_ids = [str(repo["id"]) for repo in repos_removed]
143+
existing_repos = repository_service.get_repositories(
144+
organization_id=rpc_org.id,
145+
integration_id=integration.id,
146+
providers=[provider],
147+
)
148+
repo_by_eid = {r.external_id: r for r in existing_repos if r.external_id}
149+
131150
repository_service.disable_repositories_by_external_ids(
132151
organization_id=rpc_org.id,
133152
integration_id=integration.id,
134153
provider=provider,
135154
external_ids=external_ids,
136155
)
156+
157+
for repo in repos_removed:
158+
eid = str(repo["id"])
159+
sentry_repo = repo_by_eid.get(eid)
160+
if sentry_repo:
161+
log_repo_change(
162+
event_name="REPO_DISABLED",
163+
organization_id=rpc_org.id,
164+
repo=sentry_repo,
165+
source="GitHub webhook",
166+
provider=integration.provider,
167+
)
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""
2+
Audit log helpers for repository sync operations.
3+
"""
4+
5+
from sentry import audit_log
6+
from sentry.integrations.services.repository.model import RpcRepository
7+
from sentry.utils.audit import create_system_audit_entry
8+
9+
10+
def log_repo_change(
11+
*, event_name: str, organization_id: int, repo: RpcRepository, source: str, provider: str
12+
) -> None:
13+
create_system_audit_entry(
14+
organization_id=organization_id,
15+
target_object=repo.id,
16+
event=audit_log.get_event_id(event_name),
17+
data={
18+
"repo_name": repo.name,
19+
"external_id": repo.external_id,
20+
"source": source,
21+
"provider": provider,
22+
},
23+
)

src/sentry/plugins/providers/integration_repository.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,13 +240,14 @@ def create_repositories(
240240
self,
241241
configs: list[RepositoryInputConfig],
242242
organization: RpcOrganization,
243-
):
243+
) -> list[RpcRepository]:
244244
external_id_to_repo_config: dict[str, RepositoryConfig] = {}
245245
for config in configs:
246246
result = self.build_repository_config(organization=organization, data=config)
247247
external_id_to_repo_config[result["external_id"]] = result
248248

249249
repos_to_update: list[RpcRepository] = []
250+
created_repos: list[RpcRepository] = []
250251

251252
hidden_repos = repository_service.get_repositories(
252253
organization_id=organization.id,
@@ -272,6 +273,7 @@ def create_repositories(
272273
organization_id=organization.id, create=create_repository
273274
)
274275
if new_repository is not None:
276+
created_repos.append(new_repository)
275277
continue
276278

277279
missing_repos.append(repo_config)
@@ -302,6 +304,8 @@ def create_repositories(
302304
if missing_repos:
303305
raise RepoExistsError(repos=missing_repos)
304306

307+
return created_repos + repos_to_update
308+
305309
def dispatch(self, request: Request, organization, **kwargs):
306310
try:
307311
config = self.get_repository_data(organization, request.data)

tests/sentry/integrations/github/tasks/test_sync_repos.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,16 @@
44
import responses
55
from taskbroker_client.retry import RetryTaskError
66

7+
from sentry import audit_log
78
from sentry.constants import ObjectStatus
89
from sentry.integrations.github.integration import GitHubIntegrationProvider
910
from sentry.integrations.github.tasks.sync_repos import sync_repos_for_org
1011
from sentry.integrations.models.organization_integration import OrganizationIntegration
12+
from sentry.models.auditlogentry import AuditLogEntry
1113
from sentry.models.repository import Repository
1214
from sentry.silo.base import SiloMode
1315
from sentry.testutils.cases import IntegrationTestCase
14-
from sentry.testutils.silo import assume_test_silo_mode, control_silo_test
16+
from sentry.testutils.silo import assume_test_silo_mode, assume_test_silo_mode_of, control_silo_test
1517

1618

1719
@control_silo_test
@@ -60,6 +62,13 @@ def test_creates_new_repos(self, _: MagicMock) -> None:
6062
assert repos[0].provider == "integrations:github"
6163
assert repos[1].name == "getsentry/snuba"
6264

65+
with assume_test_silo_mode_of(AuditLogEntry):
66+
entries = AuditLogEntry.objects.filter(
67+
organization_id=self.organization.id,
68+
event=audit_log.get_event_id("REPO_ADDED"),
69+
)
70+
assert entries.count() == 2
71+
6372
@responses.activate
6473
def test_disables_removed_repos(self, _: MagicMock) -> None:
6574
with assume_test_silo_mode(SiloMode.CELL):
@@ -89,6 +98,16 @@ def test_disables_removed_repos(self, _: MagicMock) -> None:
8998
organization_id=self.organization.id, external_id="1"
9099
).exists()
91100

101+
with assume_test_silo_mode_of(AuditLogEntry):
102+
assert AuditLogEntry.objects.filter(
103+
organization_id=self.organization.id,
104+
event=audit_log.get_event_id("REPO_DISABLED"),
105+
).exists()
106+
assert AuditLogEntry.objects.filter(
107+
organization_id=self.organization.id,
108+
event=audit_log.get_event_id("REPO_ADDED"),
109+
).exists()
110+
92111
@responses.activate
93112
def test_re_enables_restored_repos(self, _: MagicMock) -> None:
94113
with assume_test_silo_mode(SiloMode.CELL):
@@ -113,6 +132,12 @@ def test_re_enables_restored_repos(self, _: MagicMock) -> None:
113132
repo.refresh_from_db()
114133
assert repo.status == ObjectStatus.ACTIVE
115134

135+
with assume_test_silo_mode_of(AuditLogEntry):
136+
assert AuditLogEntry.objects.filter(
137+
organization_id=self.organization.id,
138+
event=audit_log.get_event_id("REPO_ENABLED"),
139+
).exists()
140+
116141
@responses.activate
117142
def test_no_changes_needed(self, _: MagicMock) -> None:
118143
with assume_test_silo_mode(SiloMode.CELL):

tests/sentry/integrations/github/tasks/test_sync_repos_on_install_change.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
from unittest.mock import MagicMock, patch
22

3+
from sentry import audit_log
34
from sentry.constants import ObjectStatus
45
from sentry.integrations.github.integration import GitHubIntegrationProvider
56
from sentry.integrations.github.tasks.sync_repos_on_install_change import (
67
sync_repos_on_install_change,
78
)
9+
from sentry.models.auditlogentry import AuditLogEntry
810
from sentry.models.repository import Repository
911
from sentry.silo.base import SiloMode
1012
from sentry.testutils.cases import IntegrationTestCase
11-
from sentry.testutils.silo import assume_test_silo_mode, control_silo_test
13+
from sentry.testutils.silo import assume_test_silo_mode, assume_test_silo_mode_of, control_silo_test
1214

1315
FEATURE_FLAG = "organizations:github-repo-auto-sync"
1416

@@ -50,6 +52,13 @@ def test_repos_added(self, _: MagicMock) -> None:
5052
assert repos[0].integration_id == self.integration.id
5153
assert repos[1].name == "getsentry/snuba"
5254

55+
with assume_test_silo_mode_of(AuditLogEntry):
56+
entries = AuditLogEntry.objects.filter(
57+
organization_id=self.organization.id,
58+
event=audit_log.get_event_id("REPO_ADDED"),
59+
)
60+
assert entries.count() == 2
61+
5362
def test_repos_removed(self, _: MagicMock) -> None:
5463
with assume_test_silo_mode(SiloMode.CELL):
5564
repo = Repository.objects.create(
@@ -74,6 +83,12 @@ def test_repos_removed(self, _: MagicMock) -> None:
7483
repo.refresh_from_db()
7584
assert repo.status == ObjectStatus.DISABLED
7685

86+
with assume_test_silo_mode_of(AuditLogEntry):
87+
assert AuditLogEntry.objects.filter(
88+
organization_id=self.organization.id,
89+
event=audit_log.get_event_id("REPO_DISABLED"),
90+
).exists()
91+
7792
def test_mixed_add_and_remove(self, _: MagicMock) -> None:
7893
with assume_test_silo_mode(SiloMode.CELL):
7994
old_repo = Repository.objects.create(

0 commit comments

Comments
 (0)