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,106 @@
|
|
|
1
|
+
"""The notification outbox.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
domain transaction (scan result / policy version / installation state)
|
|
6
|
+
├── state change
|
|
7
|
+
├── audit event
|
|
8
|
+
└── notification event ── emit(db, event) <- same transaction
|
|
9
|
+
│
|
|
10
|
+
dispatcher (background) ▼
|
|
11
|
+
notification_events WHERE dispatched_at IS NULL
|
|
12
|
+
-> in-app notifications, e-mail and webhook deliveries (one transaction)
|
|
13
|
+
|
|
14
|
+
Because the event is written with the state change, a crash can never leave a
|
|
15
|
+
policy change or a blocked commit without its notification, and a failed
|
|
16
|
+
transaction never produces a notification for a change that did not happen.
|
|
17
|
+
Delivery is a separate, later step: a provider outage cannot undo or delay the
|
|
18
|
+
security decision.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import sqlite3
|
|
23
|
+
from datetime import datetime
|
|
24
|
+
|
|
25
|
+
from commitguard.notifications.deduplication import storage_key
|
|
26
|
+
from commitguard.notifications.models import NotificationEvent
|
|
27
|
+
from commitguard.observability.logging import current_correlation, get_logger
|
|
28
|
+
|
|
29
|
+
log = get_logger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def emit(db: sqlite3.Connection, event: NotificationEvent, now: datetime) -> str:
|
|
33
|
+
"""Write ``event`` inside the caller's transaction. Returns the stored event ID.
|
|
34
|
+
|
|
35
|
+
A repeat of an event with the same storage key (see
|
|
36
|
+
:mod:`commitguard.notifications.deduplication`) updates the stored event and
|
|
37
|
+
marks its in-app notifications unread again; it does not fan out again.
|
|
38
|
+
"""
|
|
39
|
+
key = storage_key(event.type, event.dedup_key, now)
|
|
40
|
+
correlation = current_correlation()
|
|
41
|
+
|
|
42
|
+
def _corr(name: str) -> str | None:
|
|
43
|
+
value = correlation.get(name)
|
|
44
|
+
return None if value is None else str(value)[:128]
|
|
45
|
+
|
|
46
|
+
cursor = db.execute(
|
|
47
|
+
"INSERT INTO notification_events (event_id, account_id, type, severity, installation_id, "
|
|
48
|
+
"repository_id, resource_type, resource_id, dedup_key, title, body, metadata, "
|
|
49
|
+
"occurrences, created_at, last_occurred_at, request_id, delivery_id, job_id) "
|
|
50
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?) "
|
|
51
|
+
"ON CONFLICT (account_id, dedup_key) DO NOTHING",
|
|
52
|
+
(
|
|
53
|
+
event.event_id,
|
|
54
|
+
event.account_id,
|
|
55
|
+
event.type.value,
|
|
56
|
+
event.severity.value,
|
|
57
|
+
event.installation_id,
|
|
58
|
+
event.repository_id,
|
|
59
|
+
event.resource_type,
|
|
60
|
+
event.resource_id,
|
|
61
|
+
key,
|
|
62
|
+
event.title,
|
|
63
|
+
event.body,
|
|
64
|
+
json.dumps(event.metadata, sort_keys=True),
|
|
65
|
+
now.timestamp(),
|
|
66
|
+
now.timestamp(),
|
|
67
|
+
_corr("request_id"),
|
|
68
|
+
_corr("delivery_id"),
|
|
69
|
+
_corr("job_id"),
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
if cursor.rowcount == 1:
|
|
73
|
+
log.info("notification_event_emitted", type=event.type.value, event_id=event.event_id)
|
|
74
|
+
return event.event_id
|
|
75
|
+
row = db.execute(
|
|
76
|
+
"SELECT event_id FROM notification_events WHERE account_id = ? AND dedup_key = ?",
|
|
77
|
+
(event.account_id, key),
|
|
78
|
+
).fetchone()
|
|
79
|
+
existing = str(row["event_id"])
|
|
80
|
+
db.execute(
|
|
81
|
+
"UPDATE notification_events SET occurrences = occurrences + 1, last_occurred_at = ?, "
|
|
82
|
+
"title = ?, body = ?, metadata = ?, resource_id = ? WHERE event_id = ?",
|
|
83
|
+
(
|
|
84
|
+
now.timestamp(),
|
|
85
|
+
event.title,
|
|
86
|
+
event.body,
|
|
87
|
+
json.dumps(event.metadata, sort_keys=True),
|
|
88
|
+
event.resource_id,
|
|
89
|
+
existing,
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
db.execute(
|
|
93
|
+
"UPDATE notifications SET state = CASE WHEN state = 'read' THEN 'unread' ELSE state END, "
|
|
94
|
+
"read_at = CASE WHEN state = 'read' THEN NULL ELSE read_at END, "
|
|
95
|
+
"updated_at = ?, sort_at = ? WHERE event_id = ? AND state != 'archived'",
|
|
96
|
+
(now.timestamp(), now.timestamp(), existing),
|
|
97
|
+
)
|
|
98
|
+
log.info("notification_event_coalesced", type=event.type.value, event_id=existing)
|
|
99
|
+
return existing
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def account_for_installation(db: sqlite3.Connection, installation_id: int) -> int | None:
|
|
103
|
+
row = db.execute(
|
|
104
|
+
"SELECT account_id FROM installations WHERE installation_id = ?", (int(installation_id),)
|
|
105
|
+
).fetchone()
|
|
106
|
+
return int(row["account_id"]) if row is not None else None
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Notification preferences: organization defaults, personal choices, effective result.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
built-in defaults (per type)
|
|
6
|
+
+ organization settings administrators (notifications:manage), versioned
|
|
7
|
+
+ personal preferences each member, in-app only, for their own inbox
|
|
8
|
+
= effective preferences
|
|
9
|
+
|
|
10
|
+
Ownership rules:
|
|
11
|
+
|
|
12
|
+
* **Organization settings** decide which types reach the organization's e-mail
|
|
13
|
+
recipients and webhooks, and whether a non-mandatory type appears in members'
|
|
14
|
+
inboxes at all. Only ``notifications:manage`` may change them; turning a
|
|
15
|
+
delivery *off* needs explicit confirmation.
|
|
16
|
+
* **Personal preferences** only mute non-mandatory types in the member's own
|
|
17
|
+
inbox. They cannot touch e-mail, webhooks or anyone else's inbox, so a viewer
|
|
18
|
+
can never silence security notifications for the organization.
|
|
19
|
+
* **Mandatory** in-app types (see :mod:`commitguard.notifications.models`) are
|
|
20
|
+
delivered to every eligible member regardless of either setting.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import re
|
|
25
|
+
import sqlite3
|
|
26
|
+
from collections.abc import Mapping, Sequence
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from datetime import UTC, datetime
|
|
29
|
+
|
|
30
|
+
from commitguard.controlplane.errors import InputValidationError
|
|
31
|
+
from commitguard.notifications.models import (
|
|
32
|
+
DEFINITIONS,
|
|
33
|
+
NotificationChannel,
|
|
34
|
+
NotificationType,
|
|
35
|
+
TypeDefinition,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
MAX_EMAIL_RECIPIENTS = 20
|
|
39
|
+
MAX_EMAIL_CHARS = 254
|
|
40
|
+
# Deliberately strict: a plain mailbox address, no display name, comments or quoting.
|
|
41
|
+
_EMAIL_RE = re.compile(
|
|
42
|
+
r"\A[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]{1,64}@[A-Za-z0-9-]{1,63}(\.[A-Za-z0-9-]{1,63})+\Z"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True)
|
|
47
|
+
class ChannelSetting:
|
|
48
|
+
in_app: bool
|
|
49
|
+
email: bool
|
|
50
|
+
webhook: bool
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class OrganizationSettings:
|
|
55
|
+
account_id: int
|
|
56
|
+
version: int # 0: built-in defaults, never saved
|
|
57
|
+
types: Mapping[NotificationType, ChannelSetting]
|
|
58
|
+
email_recipients: tuple[str, ...]
|
|
59
|
+
updated_at: datetime | None = None
|
|
60
|
+
updated_by: str | None = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class UserPreferences:
|
|
65
|
+
user_id: int
|
|
66
|
+
account_id: int
|
|
67
|
+
muted: frozenset[NotificationType] = field(default_factory=frozenset)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def default_setting(definition: TypeDefinition) -> ChannelSetting:
|
|
71
|
+
return ChannelSetting(
|
|
72
|
+
in_app=definition.default_in_app or definition.mandatory_in_app,
|
|
73
|
+
email=definition.default_email,
|
|
74
|
+
webhook=definition.default_webhook,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _normalize(document: Mapping[str, object]) -> dict[NotificationType, ChannelSetting]:
|
|
79
|
+
types: dict[NotificationType, ChannelSetting] = {}
|
|
80
|
+
for notification_type, definition in DEFINITIONS.items():
|
|
81
|
+
default = default_setting(definition)
|
|
82
|
+
raw = document.get(notification_type.value)
|
|
83
|
+
entry = raw if isinstance(raw, Mapping) else {}
|
|
84
|
+
in_app = entry.get("in_app", default.in_app)
|
|
85
|
+
email = entry.get("email", default.email)
|
|
86
|
+
webhook = entry.get("webhook", default.webhook)
|
|
87
|
+
types[notification_type] = ChannelSetting(
|
|
88
|
+
in_app=True if definition.mandatory_in_app else bool(in_app),
|
|
89
|
+
email=bool(email),
|
|
90
|
+
webhook=bool(webhook),
|
|
91
|
+
)
|
|
92
|
+
return types
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def load_organization_settings(db: sqlite3.Connection, account_id: int) -> OrganizationSettings:
|
|
96
|
+
row = db.execute(
|
|
97
|
+
"SELECT * FROM notification_settings WHERE account_id = ?", (int(account_id),)
|
|
98
|
+
).fetchone()
|
|
99
|
+
if row is None:
|
|
100
|
+
return OrganizationSettings(account_id, 0, _normalize({}), ())
|
|
101
|
+
return OrganizationSettings(
|
|
102
|
+
account_id=int(account_id),
|
|
103
|
+
version=int(row["version"]),
|
|
104
|
+
types=_normalize(json.loads(row["document"])),
|
|
105
|
+
email_recipients=tuple(json.loads(row["email_recipients"])),
|
|
106
|
+
updated_at=datetime.fromtimestamp(float(row["updated_at"]), UTC),
|
|
107
|
+
updated_by=row["updated_by_login"],
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def load_user_preferences(db: sqlite3.Connection, user_id: int, account_id: int) -> UserPreferences:
|
|
112
|
+
rows = db.execute(
|
|
113
|
+
"SELECT type, in_app FROM notification_user_preferences WHERE user_id = ? "
|
|
114
|
+
"AND account_id = ?",
|
|
115
|
+
(int(user_id), int(account_id)),
|
|
116
|
+
).fetchall()
|
|
117
|
+
muted = frozenset(
|
|
118
|
+
NotificationType(r["type"]) for r in rows if not r["in_app"] and r["type"] in DEFINITIONS
|
|
119
|
+
)
|
|
120
|
+
return UserPreferences(int(user_id), int(account_id), muted)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def in_app_enabled(
|
|
124
|
+
definition: TypeDefinition,
|
|
125
|
+
organization: OrganizationSettings,
|
|
126
|
+
user: UserPreferences | None,
|
|
127
|
+
) -> bool:
|
|
128
|
+
if definition.mandatory_in_app:
|
|
129
|
+
return True
|
|
130
|
+
if not organization.types[definition.type].in_app:
|
|
131
|
+
return False
|
|
132
|
+
return user is None or definition.type not in user.muted
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# --------------------------------------------------------------------------- #
|
|
136
|
+
# Validation of API input
|
|
137
|
+
# --------------------------------------------------------------------------- #
|
|
138
|
+
def parse_type(value: object, field_name: str) -> NotificationType:
|
|
139
|
+
if not isinstance(value, str) or value not in {t.value for t in NotificationType}:
|
|
140
|
+
raise InputValidationError("unknown notification type", field=field_name)
|
|
141
|
+
return NotificationType(value)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def validate_organization_document(raw: object) -> dict[NotificationType, ChannelSetting]:
|
|
145
|
+
"""``{"type": {"in_app": bool, "email": bool, "webhook": bool}}`` - partial updates allowed."""
|
|
146
|
+
if not isinstance(raw, Mapping):
|
|
147
|
+
raise InputValidationError("types must be an object of notification types", field="types")
|
|
148
|
+
document: dict[str, dict[str, bool]] = {}
|
|
149
|
+
for key, entry in raw.items():
|
|
150
|
+
notification_type = parse_type(key, "types")
|
|
151
|
+
if not isinstance(entry, Mapping):
|
|
152
|
+
raise InputValidationError("each type must be an object", field=f"types.{key}")
|
|
153
|
+
clean: dict[str, bool] = {}
|
|
154
|
+
for channel, value in entry.items():
|
|
155
|
+
if channel not in {c.value for c in NotificationChannel}:
|
|
156
|
+
raise InputValidationError("unknown channel", field=f"types.{key}")
|
|
157
|
+
if not isinstance(value, bool):
|
|
158
|
+
raise InputValidationError(
|
|
159
|
+
"channel settings must be true or false", field=f"types.{key}.{channel}"
|
|
160
|
+
)
|
|
161
|
+
if (
|
|
162
|
+
channel == NotificationChannel.IN_APP.value
|
|
163
|
+
and not value
|
|
164
|
+
and DEFINITIONS[notification_type].mandatory_in_app
|
|
165
|
+
):
|
|
166
|
+
raise InputValidationError(
|
|
167
|
+
"in-app delivery of this notification type cannot be turned off",
|
|
168
|
+
field=f"types.{key}.in_app",
|
|
169
|
+
)
|
|
170
|
+
clean[channel] = value
|
|
171
|
+
document[notification_type.value] = clean
|
|
172
|
+
return _normalize(document)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def validate_email_recipients(raw: object) -> tuple[str, ...]:
|
|
176
|
+
if not isinstance(raw, Sequence) or isinstance(raw, str):
|
|
177
|
+
raise InputValidationError("email_recipients must be a list", field="email_recipients")
|
|
178
|
+
if len(raw) > MAX_EMAIL_RECIPIENTS:
|
|
179
|
+
raise InputValidationError(
|
|
180
|
+
f"at most {MAX_EMAIL_RECIPIENTS} e-mail recipients", field="email_recipients"
|
|
181
|
+
)
|
|
182
|
+
recipients: list[str] = []
|
|
183
|
+
for item in raw:
|
|
184
|
+
if (
|
|
185
|
+
not isinstance(item, str)
|
|
186
|
+
or len(item) > MAX_EMAIL_CHARS
|
|
187
|
+
or not _EMAIL_RE.match(item.strip())
|
|
188
|
+
):
|
|
189
|
+
raise InputValidationError(
|
|
190
|
+
"each recipient must be a plain e-mail address", field="email_recipients"
|
|
191
|
+
)
|
|
192
|
+
address = item.strip().lower()
|
|
193
|
+
if address not in recipients:
|
|
194
|
+
recipients.append(address)
|
|
195
|
+
return tuple(recipients)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def serialize(types: Mapping[NotificationType, ChannelSetting]) -> str:
|
|
199
|
+
return json.dumps(
|
|
200
|
+
{
|
|
201
|
+
t.value: {"in_app": s.in_app, "email": s.email, "webhook": s.webhook}
|
|
202
|
+
for t, s in sorted(types.items())
|
|
203
|
+
},
|
|
204
|
+
sort_keys=True,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def disabled_deliveries(
|
|
209
|
+
before: OrganizationSettings,
|
|
210
|
+
types: Mapping[NotificationType, ChannelSetting],
|
|
211
|
+
recipients: Sequence[str],
|
|
212
|
+
) -> list[str]:
|
|
213
|
+
"""Human-readable list of deliveries a settings change turns off (needs confirmation)."""
|
|
214
|
+
removed: list[str] = []
|
|
215
|
+
for notification_type, setting in types.items():
|
|
216
|
+
old = before.types[notification_type]
|
|
217
|
+
label = DEFINITIONS[notification_type].label
|
|
218
|
+
for channel in ("in_app", "email", "webhook"):
|
|
219
|
+
if getattr(old, channel) and not getattr(setting, channel):
|
|
220
|
+
removed.append(f"{label}: {channel.replace('_', '-')}")
|
|
221
|
+
dropped = sorted(set(before.email_recipients) - set(recipients))
|
|
222
|
+
if dropped:
|
|
223
|
+
removed.append(f"e-mail recipients removed: {len(dropped)}")
|
|
224
|
+
return removed
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Delivery worker: bounded retries for e-mail and webhook deliveries.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
pending ──claim (lease)──> attempt ──success──> sent
|
|
6
|
+
│
|
|
7
|
+
├─ retryable failure, attempts < 5 ──> pending
|
|
8
|
+
│ next_retry_at = now + 1m, 5m, 30m, 2h
|
|
9
|
+
└─ permanent failure, or 5th attempt ──> failed
|
|
10
|
+
|
|
11
|
+
* **Bounded.** At most :data:`MAX_ATTEMPTS` attempts; there is no infinite loop.
|
|
12
|
+
* **One sender at a time.** A delivery is claimed with a lease
|
|
13
|
+
(:data:`LEASE_SECONDS`) through a conditional update; another worker or
|
|
14
|
+
process skips it until the lease expires.
|
|
15
|
+
* **Idempotent.** The idempotency key travels with every attempt. If a process
|
|
16
|
+
dies after the provider accepted a message but before ``sent`` was stored,
|
|
17
|
+
the lease expires and the message is sent again with the *same* key, which
|
|
18
|
+
receivers use to discard the duplicate (at-least-once delivery).
|
|
19
|
+
* **Secondary to security.** Failures change only the delivery record. The
|
|
20
|
+
scan result, the violation, the policy version and the in-app notification
|
|
21
|
+
are already stored.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import sqlite3
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
from datetime import UTC, datetime, timedelta
|
|
27
|
+
|
|
28
|
+
from commitguard.audit.models import AuditEventType
|
|
29
|
+
from commitguard.github.storage import SqliteStateStore
|
|
30
|
+
from commitguard.notifications.channels.base import DeliveryError, DeliveryReceipt
|
|
31
|
+
from commitguard.notifications.channels.email import EmailProvider
|
|
32
|
+
from commitguard.notifications.channels.webhook import WebhookProvider
|
|
33
|
+
from commitguard.notifications.dispatcher import stored_event
|
|
34
|
+
from commitguard.notifications.models import (
|
|
35
|
+
DeliveryStatus,
|
|
36
|
+
NotificationChannel,
|
|
37
|
+
StoredNotificationEvent,
|
|
38
|
+
)
|
|
39
|
+
from commitguard.notifications.templates import render_email, render_webhook
|
|
40
|
+
from commitguard.observability.logging import correlation, get_logger
|
|
41
|
+
from commitguard.observability.metrics import (
|
|
42
|
+
NOTIFICATION_RETRIES,
|
|
43
|
+
NOTIFICATIONS_FAILED,
|
|
44
|
+
NOTIFICATIONS_SENT,
|
|
45
|
+
Metrics,
|
|
46
|
+
)
|
|
47
|
+
from commitguard.services.audit import AuditService
|
|
48
|
+
|
|
49
|
+
log = get_logger(__name__)
|
|
50
|
+
|
|
51
|
+
MAX_ATTEMPTS = 5
|
|
52
|
+
BACKOFF_SECONDS = (60, 300, 1800, 7200)
|
|
53
|
+
LEASE_SECONDS = 120
|
|
54
|
+
DELIVERY_BATCH = 50
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def retry_delay(attempts_made: int) -> timedelta | None:
|
|
58
|
+
"""Delay before the next attempt, or None when no attempt is left."""
|
|
59
|
+
if attempts_made >= MAX_ATTEMPTS:
|
|
60
|
+
return None
|
|
61
|
+
return timedelta(seconds=BACKOFF_SECONDS[min(attempts_made, len(BACKOFF_SECONDS)) - 1])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def mask_destination(channel: str, destination: str) -> str:
|
|
65
|
+
"""How a destination appears in audit events (no full e-mail addresses)."""
|
|
66
|
+
if channel != NotificationChannel.EMAIL.value or "@" not in destination:
|
|
67
|
+
return destination
|
|
68
|
+
local, _, domain = destination.partition("@")
|
|
69
|
+
return f"{local[:1]}***@{domain}"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class DeliveryWorker:
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
store: SqliteStateStore,
|
|
76
|
+
audit: AuditService,
|
|
77
|
+
metrics: Metrics,
|
|
78
|
+
*,
|
|
79
|
+
email: EmailProvider | None,
|
|
80
|
+
webhook: WebhookProvider | None,
|
|
81
|
+
dashboard_origin: str | None,
|
|
82
|
+
now: Callable[[], datetime] = lambda: datetime.now(UTC),
|
|
83
|
+
) -> None:
|
|
84
|
+
self._store = store
|
|
85
|
+
self._audit = audit
|
|
86
|
+
self._metrics = metrics
|
|
87
|
+
self._email = email
|
|
88
|
+
self._webhook = webhook
|
|
89
|
+
self._origin = dashboard_origin
|
|
90
|
+
self._now = now
|
|
91
|
+
|
|
92
|
+
# ------------------------------------------------------------------ #
|
|
93
|
+
def deliver_due(self, limit: int = DELIVERY_BATCH) -> int:
|
|
94
|
+
now = self._now().timestamp()
|
|
95
|
+
rows = self._store.query(
|
|
96
|
+
"SELECT delivery_id FROM notification_deliveries WHERE status = 'pending' "
|
|
97
|
+
"AND (next_retry_at IS NULL OR next_retry_at <= ?) "
|
|
98
|
+
"AND (lease_expires_at IS NULL OR lease_expires_at < ?) ORDER BY created_at LIMIT ?",
|
|
99
|
+
(now, now, int(limit)),
|
|
100
|
+
)
|
|
101
|
+
attempted = 0
|
|
102
|
+
for row in rows:
|
|
103
|
+
if self._claim(str(row["delivery_id"])):
|
|
104
|
+
self._attempt(str(row["delivery_id"]))
|
|
105
|
+
attempted += 1
|
|
106
|
+
return attempted
|
|
107
|
+
|
|
108
|
+
def _claim(self, delivery_id: str) -> bool:
|
|
109
|
+
now = self._now().timestamp()
|
|
110
|
+
with self._store.transaction() as db:
|
|
111
|
+
return (
|
|
112
|
+
db.execute(
|
|
113
|
+
"UPDATE notification_deliveries SET lease_expires_at = ?, updated_at = ? "
|
|
114
|
+
"WHERE delivery_id = ? AND status = 'pending' AND (next_retry_at IS NULL OR "
|
|
115
|
+
"next_retry_at <= ?) AND (lease_expires_at IS NULL OR lease_expires_at < ?)",
|
|
116
|
+
(now + LEASE_SECONDS, now, delivery_id, now, now),
|
|
117
|
+
).rowcount
|
|
118
|
+
== 1
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def _attempt(self, delivery_id: str) -> None:
|
|
122
|
+
rows = self._store.query(
|
|
123
|
+
"SELECT d.*, e.event_id AS e_event_id, i.account_login, "
|
|
124
|
+
"(SELECT owner || '/' || name FROM known_repositories k WHERE "
|
|
125
|
+
"k.installation_id = e.installation_id AND k.repository_id = e.repository_id) "
|
|
126
|
+
"AS repository_name, w.url AS webhook_url, w.removed_at AS webhook_removed_at "
|
|
127
|
+
"FROM notification_deliveries d JOIN notification_events e ON e.event_id = d.event_id "
|
|
128
|
+
"LEFT JOIN (SELECT account_id, MIN(account_login) AS account_login FROM installations "
|
|
129
|
+
"GROUP BY account_id) i ON i.account_id = d.account_id "
|
|
130
|
+
"LEFT JOIN notification_webhooks w ON w.endpoint_id = d.destination "
|
|
131
|
+
"AND w.account_id = d.account_id WHERE d.delivery_id = ?",
|
|
132
|
+
(delivery_id,),
|
|
133
|
+
)
|
|
134
|
+
if not rows:
|
|
135
|
+
return
|
|
136
|
+
row = rows[0]
|
|
137
|
+
event_rows = self._store.query(
|
|
138
|
+
"SELECT * FROM notification_events WHERE event_id = ?", (row["event_id"],)
|
|
139
|
+
)
|
|
140
|
+
stored = stored_event(event_rows[0])
|
|
141
|
+
organization = row["account_login"] or f"account {row['account_id']}"
|
|
142
|
+
channel = str(row["channel"])
|
|
143
|
+
with correlation(notification_event_id=stored.event.event_id):
|
|
144
|
+
try:
|
|
145
|
+
receipt = self._send(row, stored, organization, channel)
|
|
146
|
+
except DeliveryError as exc:
|
|
147
|
+
self._failed(row, exc)
|
|
148
|
+
except Exception as exc: # noqa: BLE001 - a delivery bug must not stop the worker
|
|
149
|
+
log.error("notification_delivery_crashed", error_type=type(exc).__name__)
|
|
150
|
+
self._failed(row, DeliveryError("internal_error"))
|
|
151
|
+
else:
|
|
152
|
+
self._sent(row, receipt)
|
|
153
|
+
|
|
154
|
+
def _send(
|
|
155
|
+
self,
|
|
156
|
+
row: sqlite3.Row,
|
|
157
|
+
stored: StoredNotificationEvent,
|
|
158
|
+
organization: str,
|
|
159
|
+
channel: str,
|
|
160
|
+
) -> DeliveryReceipt:
|
|
161
|
+
if channel == NotificationChannel.EMAIL.value:
|
|
162
|
+
if self._email is None:
|
|
163
|
+
raise _Cancelled("email_channel_unavailable")
|
|
164
|
+
return self._email.send(
|
|
165
|
+
render_email(
|
|
166
|
+
stored,
|
|
167
|
+
to=str(row["destination"]),
|
|
168
|
+
organization=organization,
|
|
169
|
+
dashboard_origin=self._origin,
|
|
170
|
+
),
|
|
171
|
+
idempotency_key=str(row["idempotency_key"]),
|
|
172
|
+
)
|
|
173
|
+
if self._webhook is None:
|
|
174
|
+
raise _Cancelled("webhook_channel_unavailable")
|
|
175
|
+
if row["webhook_url"] is None or row["webhook_removed_at"] is not None:
|
|
176
|
+
raise _Cancelled("webhook_endpoint_removed")
|
|
177
|
+
now = self._now()
|
|
178
|
+
return self._webhook.send(
|
|
179
|
+
url=str(row["webhook_url"]),
|
|
180
|
+
endpoint_id=str(row["destination"]),
|
|
181
|
+
event_type=stored.event.type.value,
|
|
182
|
+
body=render_webhook(
|
|
183
|
+
stored,
|
|
184
|
+
organization=organization,
|
|
185
|
+
repository=row["repository_name"],
|
|
186
|
+
dashboard_origin=self._origin,
|
|
187
|
+
now=now,
|
|
188
|
+
),
|
|
189
|
+
idempotency_key=str(row["idempotency_key"]),
|
|
190
|
+
timestamp=int(now.timestamp()),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# ------------------------------------------------------------------ #
|
|
194
|
+
def _sent(self, row: sqlite3.Row, receipt: DeliveryReceipt) -> None:
|
|
195
|
+
now = self._now().timestamp()
|
|
196
|
+
with self._store.transaction() as db:
|
|
197
|
+
db.execute(
|
|
198
|
+
"UPDATE notification_deliveries SET status = 'sent', attempt_count = "
|
|
199
|
+
"attempt_count + 1, provider = ?, provider_message_id = ?, failure_code = NULL, "
|
|
200
|
+
"last_attempt_at = ?, next_retry_at = NULL, lease_expires_at = NULL, "
|
|
201
|
+
"updated_at = ? WHERE delivery_id = ?",
|
|
202
|
+
(
|
|
203
|
+
receipt.provider,
|
|
204
|
+
(receipt.provider_message_id or "")[:200] or None,
|
|
205
|
+
now,
|
|
206
|
+
now,
|
|
207
|
+
row["delivery_id"],
|
|
208
|
+
),
|
|
209
|
+
)
|
|
210
|
+
event = self._store.insert_audit_event(
|
|
211
|
+
db,
|
|
212
|
+
self._audit.build(
|
|
213
|
+
AuditEventType.NOTIFICATION_DELIVERED,
|
|
214
|
+
account_id=int(row["account_id"]),
|
|
215
|
+
channel=str(row["channel"]),
|
|
216
|
+
destination=mask_destination(str(row["channel"]), str(row["destination"])),
|
|
217
|
+
notification_delivery=str(row["delivery_id"]),
|
|
218
|
+
notification_event=str(row["event_id"]),
|
|
219
|
+
attempts=int(row["attempt_count"]) + 1,
|
|
220
|
+
),
|
|
221
|
+
)
|
|
222
|
+
self._audit.log_stored(event)
|
|
223
|
+
self._metrics.increment(NOTIFICATIONS_SENT, channel=str(row["channel"]))
|
|
224
|
+
|
|
225
|
+
def _failed(self, row: sqlite3.Row, error: DeliveryError) -> None:
|
|
226
|
+
now = self._now()
|
|
227
|
+
attempts = int(row["attempt_count"]) + 1
|
|
228
|
+
cancelled = isinstance(error, _Cancelled)
|
|
229
|
+
delay = None if (error.permanent or cancelled) else retry_delay(attempts)
|
|
230
|
+
if cancelled:
|
|
231
|
+
status = DeliveryStatus.CANCELLED
|
|
232
|
+
elif delay is None:
|
|
233
|
+
status = DeliveryStatus.FAILED
|
|
234
|
+
else:
|
|
235
|
+
status = DeliveryStatus.PENDING
|
|
236
|
+
with self._store.transaction() as db:
|
|
237
|
+
db.execute(
|
|
238
|
+
"UPDATE notification_deliveries SET status = ?, attempt_count = ?, "
|
|
239
|
+
"failure_code = ?, last_attempt_at = ?, next_retry_at = ?, "
|
|
240
|
+
"lease_expires_at = NULL, updated_at = ? WHERE delivery_id = ?",
|
|
241
|
+
(
|
|
242
|
+
status.value,
|
|
243
|
+
attempts,
|
|
244
|
+
error.code,
|
|
245
|
+
now.timestamp(),
|
|
246
|
+
(now + delay).timestamp() if delay is not None else None,
|
|
247
|
+
now.timestamp(),
|
|
248
|
+
row["delivery_id"],
|
|
249
|
+
),
|
|
250
|
+
)
|
|
251
|
+
event = self._store.insert_audit_event(
|
|
252
|
+
db,
|
|
253
|
+
self._audit.build(
|
|
254
|
+
AuditEventType.NOTIFICATION_DELIVERY_FAILED,
|
|
255
|
+
account_id=int(row["account_id"]),
|
|
256
|
+
channel=str(row["channel"]),
|
|
257
|
+
destination=mask_destination(str(row["channel"]), str(row["destination"])),
|
|
258
|
+
notification_delivery=str(row["delivery_id"]),
|
|
259
|
+
notification_event=str(row["event_id"]),
|
|
260
|
+
attempts=attempts,
|
|
261
|
+
failure_code=error.code,
|
|
262
|
+
status=status.value,
|
|
263
|
+
retry_scheduled=delay is not None,
|
|
264
|
+
),
|
|
265
|
+
)
|
|
266
|
+
self._audit.log_stored(event)
|
|
267
|
+
if status is DeliveryStatus.PENDING:
|
|
268
|
+
self._metrics.increment(NOTIFICATION_RETRIES, channel=str(row["channel"]))
|
|
269
|
+
else:
|
|
270
|
+
self._metrics.increment(NOTIFICATIONS_FAILED, channel=str(row["channel"]))
|
|
271
|
+
log.warning(
|
|
272
|
+
"notification_delivery_failed",
|
|
273
|
+
channel=str(row["channel"]),
|
|
274
|
+
failure_code=error.code,
|
|
275
|
+
attempts=attempts,
|
|
276
|
+
status=status.value,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class _Cancelled(DeliveryError):
|
|
281
|
+
def __init__(self, code: str) -> None:
|
|
282
|
+
super().__init__(code, permanent=True)
|