commitguardian 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. commitguard/__init__.py +26 -0
  2. commitguard/__main__.py +6 -0
  3. commitguard/api/__init__.py +18 -0
  4. commitguard/api/app.py +1376 -0
  5. commitguard/api/governance.py +1085 -0
  6. commitguard/api/hosting.py +196 -0
  7. commitguard/api/http.py +252 -0
  8. commitguard/api/settings.py +169 -0
  9. commitguard/audit/__init__.py +13 -0
  10. commitguard/audit/logger.py +34 -0
  11. commitguard/audit/models.py +222 -0
  12. commitguard/audit/storage.py +59 -0
  13. commitguard/ci/__init__.py +7 -0
  14. commitguard/ci/context.py +60 -0
  15. commitguard/cli/__init__.py +6 -0
  16. commitguard/cli/app.py +74 -0
  17. commitguard/cli/commands/__init__.py +1 -0
  18. commitguard/cli/commands/benchmark.py +441 -0
  19. commitguard/cli/commands/check.py +100 -0
  20. commitguard/cli/commands/ci.py +165 -0
  21. commitguard/cli/commands/dashboard.py +141 -0
  22. commitguard/cli/commands/doctor.py +533 -0
  23. commitguard/cli/commands/github.py +449 -0
  24. commitguard/cli/commands/hook.py +156 -0
  25. commitguard/cli/commands/init.py +137 -0
  26. commitguard/cli/commands/install.py +152 -0
  27. commitguard/cli/commands/policy.py +36 -0
  28. commitguard/cli/commands/report.py +39 -0
  29. commitguard/cli/commands/reproduce.py +123 -0
  30. commitguard/cli/commands/scan.py +47 -0
  31. commitguard/cli/common.py +44 -0
  32. commitguard/cli/output.py +89 -0
  33. commitguard/cli/render.py +367 -0
  34. commitguard/config/__init__.py +6 -0
  35. commitguard/config/defaults.py +53 -0
  36. commitguard/config/enforcement.py +53 -0
  37. commitguard/config/loader.py +174 -0
  38. commitguard/config/schema.py +105 -0
  39. commitguard/config/sources.py +183 -0
  40. commitguard/controlplane/__init__.py +24 -0
  41. commitguard/controlplane/access.py +231 -0
  42. commitguard/controlplane/commands.py +393 -0
  43. commitguard/controlplane/errors.py +88 -0
  44. commitguard/controlplane/identity.py +478 -0
  45. commitguard/controlplane/members.py +219 -0
  46. commitguard/controlplane/notifications.py +787 -0
  47. commitguard/controlplane/pagination.py +146 -0
  48. commitguard/controlplane/policies.py +1204 -0
  49. commitguard/controlplane/queries.py +1814 -0
  50. commitguard/controlplane/results.py +909 -0
  51. commitguard/controlplane/rules.py +184 -0
  52. commitguard/controlplane/views.py +799 -0
  53. commitguard/core/__init__.py +6 -0
  54. commitguard/core/context.py +31 -0
  55. commitguard/core/decision.py +58 -0
  56. commitguard/core/engine.py +82 -0
  57. commitguard/core/result.py +177 -0
  58. commitguard/detectors/__init__.py +6 -0
  59. commitguard/detectors/base.py +58 -0
  60. commitguard/detectors/bot.py +87 -0
  61. commitguard/detectors/coauthor.py +86 -0
  62. commitguard/detectors/identity.py +76 -0
  63. commitguard/detectors/registry.py +72 -0
  64. commitguard/detectors/trailer.py +211 -0
  65. commitguard/exceptions/__init__.py +33 -0
  66. commitguard/exceptions/base.py +9 -0
  67. commitguard/exceptions/configuration.py +22 -0
  68. commitguard/exceptions/detection.py +11 -0
  69. commitguard/exceptions/git.py +41 -0
  70. commitguard/exceptions/service.py +25 -0
  71. commitguard/git/__init__.py +12 -0
  72. commitguard/git/commands.py +101 -0
  73. commitguard/git/commit.py +97 -0
  74. commitguard/git/diff.py +36 -0
  75. commitguard/git/hooks.py +527 -0
  76. commitguard/git/push.py +93 -0
  77. commitguard/git/ranges.py +71 -0
  78. commitguard/git/repository.py +447 -0
  79. commitguard/github/__init__.py +34 -0
  80. commitguard/github/actions.py +163 -0
  81. commitguard/github/app.py +935 -0
  82. commitguard/github/auth.py +217 -0
  83. commitguard/github/check_runs.py +172 -0
  84. commitguard/github/checks.py +210 -0
  85. commitguard/github/client.py +844 -0
  86. commitguard/github/enforcement_status.py +209 -0
  87. commitguard/github/errors.py +129 -0
  88. commitguard/github/events.py +563 -0
  89. commitguard/github/identifiers.py +90 -0
  90. commitguard/github/installations.py +566 -0
  91. commitguard/github/markdown.py +19 -0
  92. commitguard/github/permissions.py +70 -0
  93. commitguard/github/pull_requests.py +53 -0
  94. commitguard/github/queue.py +47 -0
  95. commitguard/github/recovery.py +124 -0
  96. commitguard/github/repositories.py +305 -0
  97. commitguard/github/server.py +52 -0
  98. commitguard/github/settings.py +174 -0
  99. commitguard/github/storage.py +2315 -0
  100. commitguard/github/webhooks.py +129 -0
  101. commitguard/github/worker.py +628 -0
  102. commitguard/github/workflow.py +286 -0
  103. commitguard/governance/__init__.py +26 -0
  104. commitguard/governance/bulk.py +765 -0
  105. commitguard/governance/cache.py +88 -0
  106. commitguard/governance/common.py +216 -0
  107. commitguard/governance/exceptions.py +861 -0
  108. commitguard/governance/groups.py +448 -0
  109. commitguard/governance/inventory.py +386 -0
  110. commitguard/governance/posture.py +1272 -0
  111. commitguard/governance/resolver.py +632 -0
  112. commitguard/governance/rollouts.py +760 -0
  113. commitguard/governance/rules.py +371 -0
  114. commitguard/governance/schedules.py +663 -0
  115. commitguard/governance/service.py +120 -0
  116. commitguard/governance/settings.py +365 -0
  117. commitguard/governance/simulation.py +618 -0
  118. commitguard/governance/workflow.py +734 -0
  119. commitguard/notifications/__init__.py +2 -0
  120. commitguard/notifications/channels/__init__.py +1 -0
  121. commitguard/notifications/channels/base.py +22 -0
  122. commitguard/notifications/channels/email.py +110 -0
  123. commitguard/notifications/channels/in_app.py +74 -0
  124. commitguard/notifications/channels/sink.py +58 -0
  125. commitguard/notifications/channels/webhook.py +233 -0
  126. commitguard/notifications/deduplication.py +57 -0
  127. commitguard/notifications/dispatcher.py +201 -0
  128. commitguard/notifications/models.py +439 -0
  129. commitguard/notifications/outbox.py +106 -0
  130. commitguard/notifications/preferences.py +224 -0
  131. commitguard/notifications/retry.py +282 -0
  132. commitguard/notifications/service.py +128 -0
  133. commitguard/notifications/settings.py +167 -0
  134. commitguard/notifications/templates.py +108 -0
  135. commitguard/observability/__init__.py +5 -0
  136. commitguard/observability/logging.py +161 -0
  137. commitguard/observability/metrics.py +105 -0
  138. commitguard/policies/__init__.py +6 -0
  139. commitguard/policies/defaults.py +48 -0
  140. commitguard/policies/evaluator.py +66 -0
  141. commitguard/policies/governance.py +498 -0
  142. commitguard/policies/loader.py +23 -0
  143. commitguard/policies/mandatory.py +52 -0
  144. commitguard/policies/model.py +46 -0
  145. commitguard/provenance/__init__.py +9 -0
  146. commitguard/provenance/author.py +146 -0
  147. commitguard/provenance/committer.py +16 -0
  148. commitguard/provenance/normalization.py +158 -0
  149. commitguard/provenance/signatures.py +34 -0
  150. commitguard/provenance/trailers.py +256 -0
  151. commitguard/research/__init__.py +26 -0
  152. commitguard/research/compare.py +231 -0
  153. commitguard/research/datasets.py +1484 -0
  154. commitguard/research/detection.py +183 -0
  155. commitguard/research/environment.py +185 -0
  156. commitguard/research/gitenv.py +108 -0
  157. commitguard/research/hooks.py +247 -0
  158. commitguard/research/metrics.py +85 -0
  159. commitguard/research/performance.py +194 -0
  160. commitguard/research/platform.py +288 -0
  161. commitguard/research/report.py +372 -0
  162. commitguard/research/repository.py +111 -0
  163. commitguard/research/reproduction.py +297 -0
  164. commitguard/research/results.py +94 -0
  165. commitguard/rules/__init__.py +11 -0
  166. commitguard/rules/data/ai-domains.yaml +51 -0
  167. commitguard/rules/data/ai-identities.yaml +131 -0
  168. commitguard/rules/data/bot-identities.yaml +53 -0
  169. commitguard/rules/data/patterns.yaml +52 -0
  170. commitguard/rules/loader.py +102 -0
  171. commitguard/rules/matcher.py +212 -0
  172. commitguard/rules/models.py +269 -0
  173. commitguard/security/__init__.py +5 -0
  174. commitguard/security/hashing.py +30 -0
  175. commitguard/security/rate_limit.py +33 -0
  176. commitguard/security/safe_yaml.py +69 -0
  177. commitguard/security/sanitization.py +85 -0
  178. commitguard/security/secrets.py +169 -0
  179. commitguard/security/validation.py +89 -0
  180. commitguard/services/__init__.py +15 -0
  181. commitguard/services/analysis.py +119 -0
  182. commitguard/services/audit.py +95 -0
  183. commitguard/services/ci.py +383 -0
  184. commitguard/services/enforcement.py +102 -0
  185. commitguard/services/hooks.py +254 -0
  186. commitguard/services/remediation.py +99 -0
  187. commitguard/services/reports.py +146 -0
  188. commitguard/services/scan.py +172 -0
  189. commitguard/utils/__init__.py +1 -0
  190. commitguard/utils/filesystem.py +72 -0
  191. commitguard/utils/platform.py +35 -0
  192. commitguard/utils/subprocess.py +84 -0
  193. commitguardian-0.1.0.dist-info/METADATA +694 -0
  194. commitguardian-0.1.0.dist-info/RECORD +197 -0
  195. commitguardian-0.1.0.dist-info/WHEEL +4 -0
  196. commitguardian-0.1.0.dist-info/entry_points.txt +2 -0
  197. commitguardian-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,787 @@
1
+ """The dashboard's notification center, preferences and organization settings.
2
+
3
+ Access model
4
+ ============
5
+
6
+ A user sees a notification only when **all** of these hold at read time:
7
+
8
+ 1. it is addressed to them (an inbox row created for their user ID when the
9
+ event was dispatched);
10
+ 2. they still have a CommitGuard role in the organization, and that role grants
11
+ the notification type's permission (for example ``github:manage`` for
12
+ installation disconnects);
13
+ 3. GitHub reported the organization's installation for their session at
14
+ sign-in - including an installation that has since been removed, so
15
+ administrators can read the disconnect notification about it;
16
+ 4. for a repository notification, GitHub reported that repository for their
17
+ session.
18
+
19
+ Anything else is "not found", exactly like every other resource. Marking read
20
+ or archiving touches only the caller's own inbox row.
21
+
22
+ Organization settings (``notifications:manage``) are versioned with optimistic
23
+ concurrency; changes that turn a delivery off need ``confirm: true``. Adding a
24
+ webhook endpoint sends security data outside CommitGuard, so it needs explicit
25
+ confirmation and a recent sign-in, and its signing secret is shown only once.
26
+ """
27
+
28
+ import json
29
+ import sqlite3
30
+ import uuid
31
+ from collections.abc import Callable, Mapping
32
+ from dataclasses import dataclass
33
+ from datetime import UTC, datetime
34
+ from typing import Any
35
+ from urllib.parse import urlsplit
36
+
37
+ from commitguard.audit.models import Actor, AuditEventType
38
+ from commitguard.controlplane.access import Permission, Principal, Role
39
+ from commitguard.controlplane.errors import (
40
+ ConfirmationRequiredError,
41
+ ConflictError,
42
+ InputValidationError,
43
+ NotFoundError,
44
+ PermissionDeniedError,
45
+ ReauthenticationRequiredError,
46
+ )
47
+ from commitguard.controlplane.pagination import Page, decode_cursor, encode_cursor
48
+ from commitguard.controlplane.policies import REAUTHENTICATION_WINDOW
49
+ from commitguard.controlplane.views import (
50
+ ChannelPreferenceView,
51
+ CreatedWebhookView,
52
+ NotificationChannelsView,
53
+ NotificationCounts,
54
+ NotificationDeliveryView,
55
+ NotificationView,
56
+ OrganizationNotificationSettingsView,
57
+ OrganizationRef,
58
+ RepositoryLink,
59
+ TypePreferenceView,
60
+ WebhookEndpointView,
61
+ )
62
+ from commitguard.core.result import Severity
63
+ from commitguard.github.storage import SqliteStateStore
64
+ from commitguard.notifications.channels.webhook import (
65
+ WebhookUrlError,
66
+ endpoint_secret,
67
+ validate_webhook_url,
68
+ )
69
+ from commitguard.notifications.models import (
70
+ DEFINITIONS,
71
+ NotificationCategory,
72
+ NotificationState,
73
+ NotificationType,
74
+ )
75
+ from commitguard.notifications.preferences import (
76
+ disabled_deliveries,
77
+ load_organization_settings,
78
+ load_user_preferences,
79
+ parse_type,
80
+ serialize,
81
+ validate_email_recipients,
82
+ validate_organization_document,
83
+ )
84
+ from commitguard.notifications.retry import mask_destination
85
+ from commitguard.notifications.settings import NotificationSettings
86
+ from commitguard.notifications.templates import resource_path
87
+ from commitguard.services.audit import AuditService
88
+
89
+ MAX_WEBHOOKS = 10
90
+ MAX_READ_ALL = 10_000
91
+ MAX_COUNTED = 1000
92
+ CATEGORY_FILTERS = ("critical", *[c.value for c in NotificationCategory])
93
+
94
+
95
+ def _dt(value: float | None) -> datetime | None:
96
+ return None if value is None else datetime.fromtimestamp(float(value), UTC)
97
+
98
+
99
+ @dataclass(frozen=True, slots=True)
100
+ class _Access:
101
+ """Organizations whose notifications the session may read, with the member's role."""
102
+
103
+ roles: Mapping[int, Role]
104
+ logins: Mapping[int, tuple[str, str]] # account -> (login, type)
105
+ installations: tuple[int, ...]
106
+
107
+ def pairs_json(self) -> str:
108
+ """[[account_id, type], ...] the caller's roles may read."""
109
+ return json.dumps(
110
+ [
111
+ [account_id, notification_type.value]
112
+ for account_id, role in sorted(self.roles.items())
113
+ for notification_type, definition in DEFINITIONS.items()
114
+ if definition.permission in role.permissions
115
+ ]
116
+ )
117
+
118
+
119
+ _VISIBLE = (
120
+ "n.user_id = ? AND EXISTS (SELECT 1 FROM json_each(?) p WHERE "
121
+ "json_extract(p.value, '$[0]') = e.account_id AND json_extract(p.value, '$[1]') = e.type) "
122
+ "AND (e.installation_id IS NULL OR e.installation_id IN (SELECT value FROM json_each(?))) "
123
+ "AND (e.repository_id IS NULL OR EXISTS (SELECT 1 FROM session_repositories sr WHERE "
124
+ "sr.session_hash = ? AND sr.installation_id = e.installation_id "
125
+ "AND sr.repository_id = e.repository_id))"
126
+ )
127
+ _COLUMNS = (
128
+ "n.notification_id, n.state, n.read_at, n.sort_at, e.event_id, e.type, "
129
+ "e.severity, e.title, e.body, e.account_id, e.installation_id, e.repository_id, "
130
+ "e.resource_type, e.resource_id, e.occurrences, e.created_at, e.last_occurred_at, "
131
+ "(SELECT owner || '/' || name FROM known_repositories k WHERE "
132
+ "k.installation_id = e.installation_id AND k.repository_id = e.repository_id) AS full_name"
133
+ )
134
+
135
+
136
+ _FROM = "FROM notifications n JOIN notification_events e ON e.event_id = n.event_id"
137
+
138
+
139
+ class NotificationCenter:
140
+ def __init__(
141
+ self,
142
+ store: SqliteStateStore,
143
+ audit: AuditService,
144
+ settings: NotificationSettings,
145
+ *,
146
+ now: Callable[[], datetime] = lambda: datetime.now(UTC),
147
+ ) -> None:
148
+ self._store = store
149
+ self._audit = audit
150
+ self._settings = settings
151
+ self._now = now
152
+
153
+ # ------------------------------------------------------------------ #
154
+ # Access
155
+ # ------------------------------------------------------------------ #
156
+ def _access(self, principal: Principal) -> _Access:
157
+ rows = self._store.query(
158
+ "SELECT i.installation_id, i.account_id, i.account_login, i.account_type "
159
+ "FROM session_installations si JOIN installations i ON i.installation_id = "
160
+ "si.installation_id WHERE si.session_hash = ?",
161
+ (principal.session_hash,),
162
+ )
163
+ accounts = {int(r["account_id"]): (r["account_login"], r["account_type"]) for r in rows}
164
+ roles: dict[int, Role] = {}
165
+ for row in self._store.query(
166
+ "SELECT account_id, role FROM memberships WHERE user_id = ?", (principal.user_id,)
167
+ ):
168
+ if int(row["account_id"]) in accounts and row["role"] in {r.value for r in Role}:
169
+ roles[int(row["account_id"])] = Role(row["role"])
170
+ for account_id, (_, account_type) in accounts.items():
171
+ if account_type == "User" and account_id == principal.user_id:
172
+ roles[account_id] = Role.OWNER
173
+ installations = tuple(
174
+ sorted(int(r["installation_id"]) for r in rows if int(r["account_id"]) in roles)
175
+ )
176
+ return _Access(roles, accounts, installations)
177
+
178
+ def _visible_params(self, principal: Principal, access: _Access) -> tuple[Any, ...]:
179
+ return (
180
+ principal.user_id,
181
+ access.pairs_json(),
182
+ json.dumps(list(access.installations)),
183
+ principal.session_hash,
184
+ )
185
+
186
+ def _organization(
187
+ self, principal: Principal, organization_id: int, permission: Permission
188
+ ) -> tuple[OrganizationRef, Role]:
189
+ access = self._access(principal)
190
+ role = access.roles.get(organization_id)
191
+ if role is None:
192
+ raise NotFoundError()
193
+ if permission not in role.permissions:
194
+ raise PermissionDeniedError()
195
+ login, account_type = access.logins[organization_id]
196
+ return OrganizationRef(id=organization_id, login=login, type=account_type), role
197
+
198
+ # ------------------------------------------------------------------ #
199
+ # Inbox
200
+ # ------------------------------------------------------------------ #
201
+ @staticmethod
202
+ def _view(row: sqlite3.Row) -> NotificationView:
203
+ definition = DEFINITIONS[NotificationType(row["type"])]
204
+ repository = None
205
+ if row["repository_id"] is not None and row["installation_id"] is not None:
206
+ repository = RepositoryLink(
207
+ id=row["repository_id"],
208
+ installation_id=row["installation_id"],
209
+ full_name=row["full_name"] or f"repository {row['repository_id']}",
210
+ )
211
+ return NotificationView(
212
+ id=row["notification_id"],
213
+ type=row["type"],
214
+ category=definition.category.value,
215
+ severity=Severity(row["severity"]),
216
+ state=row["state"],
217
+ title=row["title"],
218
+ body=row["body"],
219
+ organization_id=row["account_id"],
220
+ repository=repository,
221
+ resource_type=row["resource_type"],
222
+ resource_id=row["resource_id"],
223
+ link=resource_path(row["resource_type"], row["resource_id"]),
224
+ occurrences=row["occurrences"],
225
+ created_at=datetime.fromtimestamp(float(row["created_at"]), UTC),
226
+ last_occurred_at=datetime.fromtimestamp(float(row["last_occurred_at"]), UTC),
227
+ read_at=_dt(row["read_at"]),
228
+ )
229
+
230
+ def list_notifications(
231
+ self,
232
+ principal: Principal,
233
+ *,
234
+ state: str | None,
235
+ category: str | None,
236
+ organization_id: int | None,
237
+ cursor: str | None,
238
+ limit: int,
239
+ ) -> Page[NotificationView]:
240
+ access = self._access(principal)
241
+ clauses = [_VISIBLE]
242
+ params: list[Any] = list(self._visible_params(principal, access))
243
+ if state is None:
244
+ clauses.append("n.state != 'archived'")
245
+ else:
246
+ clauses.append("n.state = ?")
247
+ params.append(state)
248
+ if category == "critical":
249
+ clauses.append("n.severity = 'critical'")
250
+ elif category is not None:
251
+ types = [t.value for t, d in DEFINITIONS.items() if d.category.value == category]
252
+ clauses.append("e.type IN (SELECT value FROM json_each(?))")
253
+ params.append(json.dumps(types))
254
+ if organization_id is not None:
255
+ clauses.append("e.account_id = ?")
256
+ params.append(organization_id)
257
+ position = decode_cursor(cursor, (float, str))
258
+ if position is not None:
259
+ clauses.append("(n.sort_at < ? OR (n.sort_at = ? AND n.notification_id < ?))")
260
+ params.extend([position[0], position[0], position[1]])
261
+ # Clauses are static SQL fragments from this module; values are bound parameters.
262
+ rows = self._store.query(
263
+ " ".join(
264
+ (
265
+ "SELECT",
266
+ _COLUMNS,
267
+ _FROM,
268
+ "WHERE",
269
+ " AND ".join(clauses),
270
+ "ORDER BY n.sort_at DESC, n.notification_id DESC LIMIT ?",
271
+ )
272
+ ),
273
+ (*params, limit + 1),
274
+ )
275
+ items = [self._view(r) for r in rows[:limit]]
276
+ next_cursor = None
277
+ if len(rows) > limit:
278
+ last = rows[limit - 1]
279
+ next_cursor = encode_cursor([float(last["sort_at"]), last["notification_id"]])
280
+ return Page(items, next_cursor, limit)
281
+
282
+ def _row(self, principal: Principal, notification_id: str) -> sqlite3.Row | None:
283
+ if len(notification_id) != 32 or not all(c in "0123456789abcdef" for c in notification_id):
284
+ return None
285
+ access = self._access(principal)
286
+ rows = self._store.query(
287
+ " ".join(("SELECT", _COLUMNS, _FROM, "WHERE n.notification_id = ? AND", _VISIBLE)),
288
+ (notification_id, *self._visible_params(principal, access)),
289
+ )
290
+ return rows[0] if rows else None
291
+
292
+ def get(self, principal: Principal, notification_id: str) -> NotificationView | None:
293
+ row = self._row(principal, notification_id)
294
+ return self._view(row) if row is not None else None
295
+
296
+ def counts(self, principal: Principal) -> NotificationCounts:
297
+ """Unread counts for the bell, bounded: at most :data:`MAX_COUNTED` rows are counted."""
298
+ access = self._access(principal)
299
+ params = self._visible_params(principal, access)
300
+ unread = self._store.query(
301
+ " ".join(
302
+ (
303
+ "SELECT COUNT(*) AS n FROM (SELECT 1",
304
+ _FROM,
305
+ "WHERE n.state = 'unread' AND",
306
+ _VISIBLE,
307
+ "LIMIT ?)",
308
+ )
309
+ ),
310
+ (*params, MAX_COUNTED + 1),
311
+ )[0]["n"]
312
+ critical = self._store.query(
313
+ " ".join(
314
+ (
315
+ "SELECT COUNT(*) AS n FROM (SELECT 1",
316
+ _FROM,
317
+ "WHERE n.severity = 'critical' AND n.state = 'unread' AND",
318
+ _VISIBLE,
319
+ "LIMIT ?)",
320
+ )
321
+ ),
322
+ (*params, MAX_COUNTED + 1),
323
+ )[0]["n"]
324
+ return NotificationCounts(
325
+ unread=min(int(unread), MAX_COUNTED),
326
+ unread_critical=min(int(critical), MAX_COUNTED),
327
+ capped=int(unread) > MAX_COUNTED,
328
+ )
329
+
330
+ def set_state(
331
+ self, principal: Principal, notification_id: str, state: NotificationState
332
+ ) -> NotificationView:
333
+ row = self._row(principal, notification_id)
334
+ if row is None:
335
+ raise NotFoundError()
336
+ now = self._now().timestamp()
337
+ audit = None
338
+ with self._store.transaction() as db:
339
+ db.execute(
340
+ "UPDATE notifications SET state = ?, updated_at = ?, "
341
+ "read_at = CASE WHEN ? != 'unread' THEN COALESCE(read_at, ?) ELSE NULL END, "
342
+ "archived_at = CASE WHEN ? = 'archived' THEN ? ELSE NULL END "
343
+ "WHERE notification_id = ? AND user_id = ?",
344
+ (
345
+ state.value,
346
+ now,
347
+ state.value,
348
+ now,
349
+ state.value,
350
+ now,
351
+ notification_id,
352
+ principal.user_id,
353
+ ),
354
+ )
355
+ if (
356
+ row["state"] == NotificationState.UNREAD.value
357
+ and state is not NotificationState.UNREAD
358
+ ):
359
+ audit = self._store.insert_audit_event(
360
+ db,
361
+ self._audit.build(
362
+ AuditEventType.NOTIFICATION_READ,
363
+ actor=Actor.user(principal.user_id, principal.login),
364
+ account_id=int(row["account_id"]),
365
+ installation_id=row["installation_id"],
366
+ repository_id=row["repository_id"],
367
+ notification=notification_id,
368
+ notification_type=row["type"],
369
+ state=state.value,
370
+ ),
371
+ )
372
+ if audit is not None:
373
+ self._audit.log_stored(audit)
374
+ updated = self.get(principal, notification_id)
375
+ assert updated is not None # noqa: S101 - visible above
376
+ return updated
377
+
378
+ def mark_all_read(self, principal: Principal, organization_id: int | None) -> int:
379
+ access = self._access(principal)
380
+ params: list[Any] = list(self._visible_params(principal, access))
381
+ clause = ""
382
+ if organization_id is not None:
383
+ clause = "AND e.account_id = ?"
384
+ params.append(organization_id)
385
+ rows = self._store.query(
386
+ " ".join(
387
+ (
388
+ "SELECT n.notification_id, e.account_id",
389
+ _FROM,
390
+ "WHERE n.state = 'unread' AND",
391
+ _VISIBLE,
392
+ clause,
393
+ "LIMIT ?",
394
+ )
395
+ ),
396
+ (*params, MAX_READ_ALL),
397
+ )
398
+ if not rows:
399
+ return 0
400
+ now = self._now().timestamp()
401
+ per_account: dict[int, int] = {}
402
+ for row in rows:
403
+ per_account[int(row["account_id"])] = per_account.get(int(row["account_id"]), 0) + 1
404
+ events = []
405
+ with self._store.transaction() as db:
406
+ db.execute(
407
+ "UPDATE notifications SET state = 'read', read_at = ?, updated_at = ? "
408
+ "WHERE user_id = ? AND state = 'unread' AND notification_id IN "
409
+ "(SELECT value FROM json_each(?))",
410
+ (now, now, principal.user_id, json.dumps([r["notification_id"] for r in rows])),
411
+ )
412
+ for account_id, count in sorted(per_account.items()):
413
+ events.append(
414
+ self._store.insert_audit_event(
415
+ db,
416
+ self._audit.build(
417
+ AuditEventType.NOTIFICATION_READ,
418
+ actor=Actor.user(principal.user_id, principal.login),
419
+ account_id=account_id,
420
+ notifications=count,
421
+ state="read",
422
+ bulk=True,
423
+ ),
424
+ )
425
+ )
426
+ for event in events:
427
+ self._audit.log_stored(event)
428
+ return len(rows)
429
+
430
+ # ------------------------------------------------------------------ #
431
+ # Preferences and organization settings
432
+ # ------------------------------------------------------------------ #
433
+ def _channels(self) -> NotificationChannelsView:
434
+ return NotificationChannelsView(
435
+ in_app=True,
436
+ email=self._settings.email_available,
437
+ webhook=self._settings.webhook_available,
438
+ mode=self._settings.mode.value,
439
+ )
440
+
441
+ def _settings_view(
442
+ self, principal: Principal, organization: OrganizationRef, role: Role
443
+ ) -> OrganizationNotificationSettingsView:
444
+ with self._store.transaction() as db:
445
+ settings = load_organization_settings(db, organization.id)
446
+ personal = load_user_preferences(db, principal.user_id, organization.id)
447
+ webhooks = db.execute(
448
+ "SELECT endpoint_id, url, created_at, created_by_login FROM notification_webhooks "
449
+ "WHERE account_id = ? AND removed_at IS NULL ORDER BY created_at",
450
+ (organization.id,),
451
+ ).fetchall()
452
+ can_manage = Permission.NOTIFICATIONS_MANAGE in role.permissions
453
+ types = []
454
+ for notification_type, definition in DEFINITIONS.items():
455
+ setting = settings.types[notification_type]
456
+ types.append(
457
+ TypePreferenceView(
458
+ type=notification_type.value,
459
+ label=definition.label,
460
+ description=definition.description,
461
+ category=definition.category.value,
462
+ mandatory_in_app=definition.mandatory_in_app,
463
+ organization=ChannelPreferenceView(
464
+ in_app=setting.in_app, email=setting.email, webhook=setting.webhook
465
+ ),
466
+ personal_in_app=definition.mandatory_in_app
467
+ or notification_type not in personal.muted,
468
+ receives_in_app=definition.permission in role.permissions,
469
+ )
470
+ )
471
+ return OrganizationNotificationSettingsView(
472
+ organization=organization,
473
+ version=settings.version,
474
+ updated_at=settings.updated_at,
475
+ updated_by=settings.updated_by,
476
+ channels=self._channels(),
477
+ types=tuple(types),
478
+ email_recipients=settings.email_recipients if can_manage else (),
479
+ webhooks=tuple(
480
+ WebhookEndpointView(
481
+ id=w["endpoint_id"],
482
+ url=w["url"],
483
+ created_at=datetime.fromtimestamp(float(w["created_at"]), UTC),
484
+ created_by=w["created_by_login"],
485
+ )
486
+ for w in webhooks
487
+ )
488
+ if can_manage
489
+ else (),
490
+ can_manage=can_manage,
491
+ )
492
+
493
+ def preferences(self, principal: Principal) -> list[OrganizationNotificationSettingsView]:
494
+ access = self._access(principal)
495
+ return [
496
+ self._settings_view(
497
+ principal,
498
+ OrganizationRef(id=account_id, login=login, type=account_type),
499
+ access.roles[account_id],
500
+ )
501
+ for account_id, (login, account_type) in sorted(
502
+ access.logins.items(), key=lambda item: item[1][0].lower()
503
+ )
504
+ if account_id in access.roles
505
+ ]
506
+
507
+ def organization_settings(
508
+ self, principal: Principal, organization_id: int
509
+ ) -> OrganizationNotificationSettingsView:
510
+ organization, role = self._organization(
511
+ principal, organization_id, Permission.NOTIFICATIONS_READ
512
+ )
513
+ return self._settings_view(principal, organization, role)
514
+
515
+ def update_personal(
516
+ self, principal: Principal, organization_id: object, raw: object
517
+ ) -> OrganizationNotificationSettingsView:
518
+ if not isinstance(organization_id, int) or isinstance(organization_id, bool):
519
+ raise InputValidationError("organization_id is required", field="organization_id")
520
+ organization, role = self._organization(
521
+ principal, organization_id, Permission.NOTIFICATIONS_READ
522
+ )
523
+ if not isinstance(raw, Mapping) or not raw:
524
+ raise InputValidationError(
525
+ "in_app must be an object of notification types", field="in_app"
526
+ )
527
+ changes: dict[NotificationType, bool] = {}
528
+ for key, value in raw.items():
529
+ notification_type = parse_type(key, "in_app")
530
+ if not isinstance(value, bool):
531
+ raise InputValidationError("values must be true or false", field=f"in_app.{key}")
532
+ if DEFINITIONS[notification_type].mandatory_in_app and not value:
533
+ raise InputValidationError(
534
+ "this notification type cannot be muted", field=f"in_app.{key}"
535
+ )
536
+ changes[notification_type] = value
537
+ now = self._now().timestamp()
538
+ with self._store.transaction() as db:
539
+ db.executemany(
540
+ "INSERT INTO notification_user_preferences (user_id, account_id, type, in_app, "
541
+ "updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT (user_id, account_id, type) DO "
542
+ "UPDATE SET in_app = excluded.in_app, updated_at = excluded.updated_at",
543
+ [
544
+ (principal.user_id, organization.id, t.value, int(v), now)
545
+ for t, v in sorted(changes.items())
546
+ ],
547
+ )
548
+ event = self._store.insert_audit_event(
549
+ db,
550
+ self._audit.build(
551
+ AuditEventType.NOTIFICATION_PREFERENCES_CHANGED,
552
+ actor=Actor.user(principal.user_id, principal.login),
553
+ account_id=organization.id,
554
+ scope="personal",
555
+ muted=",".join(sorted(t.value for t, v in changes.items() if not v)) or None,
556
+ unmuted=",".join(sorted(t.value for t, v in changes.items() if v)) or None,
557
+ ),
558
+ )
559
+ self._audit.log_stored(event)
560
+ return self._settings_view(principal, organization, role)
561
+
562
+ def update_organization(
563
+ self, principal: Principal, organization_id: int, body: Mapping[str, object]
564
+ ) -> OrganizationNotificationSettingsView:
565
+ organization, role = self._organization(
566
+ principal, organization_id, Permission.NOTIFICATIONS_MANAGE
567
+ )
568
+ expected = body.get("expected_version")
569
+ if not isinstance(expected, int) or isinstance(expected, bool) or expected < 0:
570
+ raise InputValidationError(
571
+ "expected_version must be a non-negative integer", field="expected_version"
572
+ )
573
+ types = validate_organization_document(body.get("types", {}))
574
+ with self._store.transaction() as db:
575
+ current = load_organization_settings(db, organization.id)
576
+ recipients = (
577
+ validate_email_recipients(body["email_recipients"])
578
+ if "email_recipients" in body
579
+ else current.email_recipients
580
+ )
581
+ if current.version != expected:
582
+ raise ConflictError(
583
+ f"Notification settings were changed by someone else (now version "
584
+ f"{current.version}). Reload before saving."
585
+ )
586
+ disabled = disabled_deliveries(current, types, recipients)
587
+ if disabled and body.get("confirm") is not True:
588
+ raise ConfirmationRequiredError(
589
+ "This change turns off notification deliveries and must be confirmed: "
590
+ + "; ".join(disabled[:10])
591
+ )
592
+ now = self._now().timestamp()
593
+ with self._store.transaction() as db:
594
+ latest = load_organization_settings(db, organization.id)
595
+ if latest.version != expected:
596
+ raise ConflictError(
597
+ f"Notification settings were changed by someone else (now version "
598
+ f"{latest.version}). Reload before saving."
599
+ )
600
+ db.execute(
601
+ "INSERT INTO notification_settings (account_id, version, document, "
602
+ "email_recipients, updated_at, updated_by_login) VALUES (?, ?, ?, ?, ?, ?) "
603
+ "ON CONFLICT (account_id) DO UPDATE SET version = excluded.version, "
604
+ "document = excluded.document, email_recipients = excluded.email_recipients, "
605
+ "updated_at = excluded.updated_at, updated_by_login = excluded.updated_by_login",
606
+ (
607
+ organization.id,
608
+ expected + 1,
609
+ serialize(types),
610
+ json.dumps(list(recipients)),
611
+ now,
612
+ principal.login,
613
+ ),
614
+ )
615
+ added = sorted(set(recipients) - set(latest.email_recipients))
616
+ event = self._store.insert_audit_event(
617
+ db,
618
+ self._audit.build(
619
+ AuditEventType.NOTIFICATION_SETTINGS_CHANGED,
620
+ actor=Actor.user(principal.user_id, principal.login),
621
+ account_id=organization.id,
622
+ old_version=expected,
623
+ new_version=expected + 1,
624
+ disabled="; ".join(disabled)[:500] or None,
625
+ email_recipients=len(recipients),
626
+ email_recipients_added=",".join(mask_destination("email", a) for a in added)[
627
+ :500
628
+ ]
629
+ or None,
630
+ ),
631
+ )
632
+ self._audit.log_stored(event)
633
+ return self._settings_view(principal, organization, role)
634
+
635
+ # ------------------------------------------------------------------ #
636
+ # Webhook endpoints
637
+ # ------------------------------------------------------------------ #
638
+ def add_webhook(
639
+ self, principal: Principal, organization_id: int, body: Mapping[str, object]
640
+ ) -> CreatedWebhookView:
641
+ organization, _ = self._organization(
642
+ principal, organization_id, Permission.NOTIFICATIONS_MANAGE
643
+ )
644
+ if not self._settings.webhook_available or self._settings.signing_key is None:
645
+ raise ConflictError("Webhook delivery is not configured on this CommitGuard server.")
646
+ try:
647
+ url = validate_webhook_url(
648
+ body.get("url"), allow_insecure_local=not self._settings.production
649
+ )
650
+ except WebhookUrlError as exc:
651
+ raise InputValidationError(str(exc), field="url") from None
652
+ if body.get("confirm") is not True:
653
+ raise ConfirmationRequiredError(
654
+ "A webhook sends security notifications outside CommitGuard and must be confirmed."
655
+ )
656
+ now = self._now()
657
+ if now - principal.authenticated_at > REAUTHENTICATION_WINDOW:
658
+ raise ReauthenticationRequiredError()
659
+ endpoint_id = uuid.uuid4().hex
660
+ with self._store.transaction() as db:
661
+ count = db.execute(
662
+ "SELECT COUNT(*) AS n FROM notification_webhooks WHERE account_id = ? "
663
+ "AND removed_at IS NULL",
664
+ (organization.id,),
665
+ ).fetchone()["n"]
666
+ if int(count) >= MAX_WEBHOOKS:
667
+ raise ConflictError(f"An organization can have at most {MAX_WEBHOOKS} webhooks.")
668
+ db.execute(
669
+ "INSERT INTO notification_webhooks (endpoint_id, account_id, url, created_at, "
670
+ "created_by_login) VALUES (?, ?, ?, ?, ?)",
671
+ (endpoint_id, organization.id, url, now.timestamp(), principal.login),
672
+ )
673
+ event = self._store.insert_audit_event(
674
+ db,
675
+ self._audit.build(
676
+ AuditEventType.NOTIFICATION_WEBHOOK_ADDED,
677
+ actor=Actor.user(principal.user_id, principal.login),
678
+ account_id=organization.id,
679
+ endpoint=endpoint_id,
680
+ host=urlsplit(url).hostname,
681
+ ),
682
+ )
683
+ self._audit.log_stored(event)
684
+ return CreatedWebhookView(
685
+ endpoint=WebhookEndpointView(
686
+ id=endpoint_id, url=url, created_at=now, created_by=principal.login
687
+ ),
688
+ signing_secret=endpoint_secret(self._settings.signing_key, endpoint_id).reveal(),
689
+ )
690
+
691
+ def remove_webhook(self, principal: Principal, organization_id: int, endpoint_id: str) -> None:
692
+ organization, _ = self._organization(
693
+ principal, organization_id, Permission.NOTIFICATIONS_MANAGE
694
+ )
695
+ now = self._now().timestamp()
696
+ with self._store.transaction() as db:
697
+ row = db.execute(
698
+ "SELECT url FROM notification_webhooks WHERE endpoint_id = ? AND account_id = ? "
699
+ "AND removed_at IS NULL",
700
+ (endpoint_id, organization.id),
701
+ ).fetchone()
702
+ if row is None:
703
+ raise NotFoundError()
704
+ db.execute(
705
+ "UPDATE notification_webhooks SET removed_at = ? WHERE endpoint_id = ?",
706
+ (now, endpoint_id),
707
+ )
708
+ db.execute(
709
+ "UPDATE notification_deliveries SET status = 'cancelled', "
710
+ "failure_code = 'webhook_endpoint_removed', updated_at = ? "
711
+ "WHERE destination = ? AND account_id = ? AND status = 'pending'",
712
+ (now, endpoint_id, organization.id),
713
+ )
714
+ event = self._store.insert_audit_event(
715
+ db,
716
+ self._audit.build(
717
+ AuditEventType.NOTIFICATION_WEBHOOK_REMOVED,
718
+ actor=Actor.user(principal.user_id, principal.login),
719
+ account_id=organization.id,
720
+ endpoint=endpoint_id,
721
+ host=urlsplit(str(row["url"])).hostname,
722
+ ),
723
+ )
724
+ self._audit.log_stored(event)
725
+
726
+ def deliveries(
727
+ self, principal: Principal, organization_id: int, *, cursor: str | None, limit: int
728
+ ) -> Page[NotificationDeliveryView]:
729
+ organization, _ = self._organization(
730
+ principal, organization_id, Permission.NOTIFICATIONS_MANAGE
731
+ )
732
+ params: list[Any] = [organization.id]
733
+ clause = ""
734
+ position = decode_cursor(cursor, (float, str))
735
+ if position is not None:
736
+ clause = "AND (d.created_at < ? OR (d.created_at = ? AND d.delivery_id < ?))"
737
+ params.extend([position[0], position[0], position[1]])
738
+ rows = self._store.query(
739
+ " ".join(
740
+ (
741
+ "SELECT d.*, e.type, e.title FROM notification_deliveries d JOIN "
742
+ "notification_events e ON e.event_id = d.event_id WHERE d.account_id = ?",
743
+ clause,
744
+ "ORDER BY d.created_at DESC, d.delivery_id DESC LIMIT ?",
745
+ )
746
+ ),
747
+ (*params, limit + 1),
748
+ )
749
+ items = [
750
+ NotificationDeliveryView(
751
+ id=r["delivery_id"],
752
+ notification_type=r["type"],
753
+ title=r["title"],
754
+ channel=r["channel"],
755
+ destination=r["destination"],
756
+ status=r["status"],
757
+ attempts=r["attempt_count"],
758
+ failure_code=r["failure_code"],
759
+ last_attempt_at=_dt(r["last_attempt_at"]),
760
+ next_retry_at=_dt(r["next_retry_at"]),
761
+ created_at=datetime.fromtimestamp(float(r["created_at"]), UTC),
762
+ )
763
+ for r in rows[:limit]
764
+ ]
765
+ next_cursor = None
766
+ if len(rows) > limit:
767
+ last = rows[limit - 1]
768
+ next_cursor = encode_cursor([float(last["created_at"]), last["delivery_id"]])
769
+ return Page(items, next_cursor, limit)
770
+
771
+
772
+ def parse_state_filter(value: str | None) -> str | None:
773
+ if value is None or value == "" or value == "all":
774
+ return None
775
+ if value not in {s.value for s in NotificationState}:
776
+ raise InputValidationError("state must be unread, read, archived or all", field="state")
777
+ return value
778
+
779
+
780
+ def parse_category_filter(value: str | None) -> str | None:
781
+ if value is None or value == "" or value == "all":
782
+ return None
783
+ if value not in CATEGORY_FILTERS:
784
+ raise InputValidationError(
785
+ "category must be one of: all, " + ", ".join(CATEGORY_FILTERS), field="category"
786
+ )
787
+ return value