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.
- commitguard/__init__.py +26 -0
- commitguard/__main__.py +6 -0
- commitguard/api/__init__.py +18 -0
- commitguard/api/app.py +1376 -0
- commitguard/api/governance.py +1085 -0
- commitguard/api/hosting.py +196 -0
- commitguard/api/http.py +252 -0
- commitguard/api/settings.py +169 -0
- commitguard/audit/__init__.py +13 -0
- commitguard/audit/logger.py +34 -0
- commitguard/audit/models.py +222 -0
- commitguard/audit/storage.py +59 -0
- commitguard/ci/__init__.py +7 -0
- commitguard/ci/context.py +60 -0
- commitguard/cli/__init__.py +6 -0
- commitguard/cli/app.py +74 -0
- commitguard/cli/commands/__init__.py +1 -0
- commitguard/cli/commands/benchmark.py +441 -0
- commitguard/cli/commands/check.py +100 -0
- commitguard/cli/commands/ci.py +165 -0
- commitguard/cli/commands/dashboard.py +141 -0
- commitguard/cli/commands/doctor.py +533 -0
- commitguard/cli/commands/github.py +449 -0
- commitguard/cli/commands/hook.py +156 -0
- commitguard/cli/commands/init.py +137 -0
- commitguard/cli/commands/install.py +152 -0
- commitguard/cli/commands/policy.py +36 -0
- commitguard/cli/commands/report.py +39 -0
- commitguard/cli/commands/reproduce.py +123 -0
- commitguard/cli/commands/scan.py +47 -0
- commitguard/cli/common.py +44 -0
- commitguard/cli/output.py +89 -0
- commitguard/cli/render.py +367 -0
- commitguard/config/__init__.py +6 -0
- commitguard/config/defaults.py +53 -0
- commitguard/config/enforcement.py +53 -0
- commitguard/config/loader.py +174 -0
- commitguard/config/schema.py +105 -0
- commitguard/config/sources.py +183 -0
- commitguard/controlplane/__init__.py +24 -0
- commitguard/controlplane/access.py +231 -0
- commitguard/controlplane/commands.py +393 -0
- commitguard/controlplane/errors.py +88 -0
- commitguard/controlplane/identity.py +478 -0
- commitguard/controlplane/members.py +219 -0
- commitguard/controlplane/notifications.py +787 -0
- commitguard/controlplane/pagination.py +146 -0
- commitguard/controlplane/policies.py +1204 -0
- commitguard/controlplane/queries.py +1814 -0
- commitguard/controlplane/results.py +909 -0
- commitguard/controlplane/rules.py +184 -0
- commitguard/controlplane/views.py +799 -0
- commitguard/core/__init__.py +6 -0
- commitguard/core/context.py +31 -0
- commitguard/core/decision.py +58 -0
- commitguard/core/engine.py +82 -0
- commitguard/core/result.py +177 -0
- commitguard/detectors/__init__.py +6 -0
- commitguard/detectors/base.py +58 -0
- commitguard/detectors/bot.py +87 -0
- commitguard/detectors/coauthor.py +86 -0
- commitguard/detectors/identity.py +76 -0
- commitguard/detectors/registry.py +72 -0
- commitguard/detectors/trailer.py +211 -0
- commitguard/exceptions/__init__.py +33 -0
- commitguard/exceptions/base.py +9 -0
- commitguard/exceptions/configuration.py +22 -0
- commitguard/exceptions/detection.py +11 -0
- commitguard/exceptions/git.py +41 -0
- commitguard/exceptions/service.py +25 -0
- commitguard/git/__init__.py +12 -0
- commitguard/git/commands.py +101 -0
- commitguard/git/commit.py +97 -0
- commitguard/git/diff.py +36 -0
- commitguard/git/hooks.py +527 -0
- commitguard/git/push.py +93 -0
- commitguard/git/ranges.py +71 -0
- commitguard/git/repository.py +447 -0
- commitguard/github/__init__.py +34 -0
- commitguard/github/actions.py +163 -0
- commitguard/github/app.py +935 -0
- commitguard/github/auth.py +217 -0
- commitguard/github/check_runs.py +172 -0
- commitguard/github/checks.py +210 -0
- commitguard/github/client.py +844 -0
- commitguard/github/enforcement_status.py +209 -0
- commitguard/github/errors.py +129 -0
- commitguard/github/events.py +563 -0
- commitguard/github/identifiers.py +90 -0
- commitguard/github/installations.py +566 -0
- commitguard/github/markdown.py +19 -0
- commitguard/github/permissions.py +70 -0
- commitguard/github/pull_requests.py +53 -0
- commitguard/github/queue.py +47 -0
- commitguard/github/recovery.py +124 -0
- commitguard/github/repositories.py +305 -0
- commitguard/github/server.py +52 -0
- commitguard/github/settings.py +174 -0
- commitguard/github/storage.py +2315 -0
- commitguard/github/webhooks.py +129 -0
- commitguard/github/worker.py +628 -0
- commitguard/github/workflow.py +286 -0
- commitguard/governance/__init__.py +26 -0
- commitguard/governance/bulk.py +765 -0
- commitguard/governance/cache.py +88 -0
- commitguard/governance/common.py +216 -0
- commitguard/governance/exceptions.py +861 -0
- commitguard/governance/groups.py +448 -0
- commitguard/governance/inventory.py +386 -0
- commitguard/governance/posture.py +1272 -0
- commitguard/governance/resolver.py +632 -0
- commitguard/governance/rollouts.py +760 -0
- commitguard/governance/rules.py +371 -0
- commitguard/governance/schedules.py +663 -0
- commitguard/governance/service.py +120 -0
- commitguard/governance/settings.py +365 -0
- commitguard/governance/simulation.py +618 -0
- commitguard/governance/workflow.py +734 -0
- commitguard/notifications/__init__.py +2 -0
- commitguard/notifications/channels/__init__.py +1 -0
- commitguard/notifications/channels/base.py +22 -0
- commitguard/notifications/channels/email.py +110 -0
- commitguard/notifications/channels/in_app.py +74 -0
- commitguard/notifications/channels/sink.py +58 -0
- commitguard/notifications/channels/webhook.py +233 -0
- commitguard/notifications/deduplication.py +57 -0
- commitguard/notifications/dispatcher.py +201 -0
- commitguard/notifications/models.py +439 -0
- commitguard/notifications/outbox.py +106 -0
- commitguard/notifications/preferences.py +224 -0
- commitguard/notifications/retry.py +282 -0
- commitguard/notifications/service.py +128 -0
- commitguard/notifications/settings.py +167 -0
- commitguard/notifications/templates.py +108 -0
- commitguard/observability/__init__.py +5 -0
- commitguard/observability/logging.py +161 -0
- commitguard/observability/metrics.py +105 -0
- commitguard/policies/__init__.py +6 -0
- commitguard/policies/defaults.py +48 -0
- commitguard/policies/evaluator.py +66 -0
- commitguard/policies/governance.py +498 -0
- commitguard/policies/loader.py +23 -0
- commitguard/policies/mandatory.py +52 -0
- commitguard/policies/model.py +46 -0
- commitguard/provenance/__init__.py +9 -0
- commitguard/provenance/author.py +146 -0
- commitguard/provenance/committer.py +16 -0
- commitguard/provenance/normalization.py +158 -0
- commitguard/provenance/signatures.py +34 -0
- commitguard/provenance/trailers.py +256 -0
- commitguard/research/__init__.py +26 -0
- commitguard/research/compare.py +231 -0
- commitguard/research/datasets.py +1484 -0
- commitguard/research/detection.py +183 -0
- commitguard/research/environment.py +185 -0
- commitguard/research/gitenv.py +108 -0
- commitguard/research/hooks.py +247 -0
- commitguard/research/metrics.py +85 -0
- commitguard/research/performance.py +194 -0
- commitguard/research/platform.py +288 -0
- commitguard/research/report.py +372 -0
- commitguard/research/repository.py +111 -0
- commitguard/research/reproduction.py +297 -0
- commitguard/research/results.py +94 -0
- commitguard/rules/__init__.py +11 -0
- commitguard/rules/data/ai-domains.yaml +51 -0
- commitguard/rules/data/ai-identities.yaml +131 -0
- commitguard/rules/data/bot-identities.yaml +53 -0
- commitguard/rules/data/patterns.yaml +52 -0
- commitguard/rules/loader.py +102 -0
- commitguard/rules/matcher.py +212 -0
- commitguard/rules/models.py +269 -0
- commitguard/security/__init__.py +5 -0
- commitguard/security/hashing.py +30 -0
- commitguard/security/rate_limit.py +33 -0
- commitguard/security/safe_yaml.py +69 -0
- commitguard/security/sanitization.py +85 -0
- commitguard/security/secrets.py +169 -0
- commitguard/security/validation.py +89 -0
- commitguard/services/__init__.py +15 -0
- commitguard/services/analysis.py +119 -0
- commitguard/services/audit.py +95 -0
- commitguard/services/ci.py +383 -0
- commitguard/services/enforcement.py +102 -0
- commitguard/services/hooks.py +254 -0
- commitguard/services/remediation.py +99 -0
- commitguard/services/reports.py +146 -0
- commitguard/services/scan.py +172 -0
- commitguard/utils/__init__.py +1 -0
- commitguard/utils/filesystem.py +72 -0
- commitguard/utils/platform.py +35 -0
- commitguard/utils/subprocess.py +84 -0
- commitguardian-0.1.0.dist-info/METADATA +694 -0
- commitguardian-0.1.0.dist-info/RECORD +197 -0
- commitguardian-0.1.0.dist-info/WHEEL +4 -0
- commitguardian-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|