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,1204 @@
1
+ """Versioned policy for organizations, repository groups and repositories.
2
+
3
+ Policy hierarchy (see :mod:`commitguard.policies.governance` for the exact
4
+ per-rule precedence)::
5
+
6
+ service policy COMMITGUARD_APP_MANDATORY_POLICY_FILE (operator), optional
7
+ + organization policy versioned in the dashboard (this module)
8
+ + repository group policy versioned, per group (this module)
9
+ + repository policy versioned, per repository in the dashboard (this module)
10
+ + repository configuration .commitguard.yaml at the trusted revision (base commit)
11
+ + approved exceptions, monitor mode
12
+ = effective policy resolved per repository, recorded with every scan
13
+
14
+ A policy version is a set of entries, one per rule, each with a **strength**:
15
+
16
+ * ``mandatory`` (``warn`` or ``block``): a *floor*. It is applied with the same
17
+ code as the service policy (:func:`commitguard.policies.mandatory.apply_mandatory_policies`),
18
+ so a repository configuration - or a narrower dashboard policy - that disables
19
+ or lowers the rule is still evaluated at the floor, and the attempt is shown
20
+ as a policy conflict. Removing or lowering a floor is a *weakening* change: it
21
+ needs an explicit confirmation and a recent sign-in, and is audited like any change.
22
+ * ``default`` (``allow``, ``warn`` or ``block``): a baseline that narrower
23
+ levels and the repository configuration may replace in either direction.
24
+
25
+ The stored document is canonical JSON: a mandatory entry is its action
26
+ (``{"ai_coauthor": "block"}``, the Phase 6 format, so existing versions keep
27
+ their fingerprints); a default entry is ``{"action": "warn", "enforcement": "default"}``.
28
+
29
+ Versions are immutable rows (database triggers refuse ``UPDATE`` and
30
+ ``DELETE``). The organization's versions live in ``organization_policy_versions``,
31
+ group and repository versions in ``scoped_policy_versions``. Every scan stores the
32
+ policy versions and the fingerprint of the effective policy set it was evaluated
33
+ with, so a historical result keeps showing the policy that produced it.
34
+
35
+ Version lifecycle: the newest version of a target is its latest *published*
36
+ version; older versions are ``archived``. Proposed changes live in drafts
37
+ (:mod:`commitguard.governance.workflow`) until they are published. A staged
38
+ rollout (:mod:`commitguard.governance.rollouts`) may apply the newest version to
39
+ pilot repositories first; everything else keeps the previous version until the
40
+ rollout expands. An archived version becomes the basis of enforcement again only
41
+ through an explicit **rollback**, which never edits or deletes anything - it
42
+ publishes a *new* version whose document is the restored one and records the
43
+ lineage::
44
+
45
+ v13 archived Added bot restriction
46
+ v14 active rollback: restores v12 (replaced v13)
47
+
48
+ Rollback needs ``policies:rollback``, a reason, an explicit confirmation, the
49
+ version the administrator was looking at (``expected_current_version``), and -
50
+ when it weakens a floor - a recent sign-in. The target's stored fingerprint must
51
+ match its document. Version, audit event, notification and the invalidation of
52
+ affected repositories' effective policies are written in one transaction: a
53
+ failure leaves the active version unchanged.
54
+
55
+ Concurrency: an update or rollback names the version it was based on. If
56
+ another administrator published a newer version first, the request is rejected
57
+ with a conflict instead of silently overwriting their change.
58
+
59
+ Scans resolve policy from the database when they start (through the
60
+ governance resolver's cache, which every publication invalidates in the same
61
+ transaction), so a change applies to the next scan in every process and host,
62
+ and a scan that started before the change keeps reporting the version it was
63
+ evaluated with.
64
+ """
65
+
66
+ import json
67
+ import sqlite3
68
+ from collections.abc import Callable, Mapping, Sequence
69
+ from dataclasses import dataclass, field
70
+ from datetime import UTC, datetime, timedelta
71
+ from enum import StrEnum
72
+ from typing import Literal
73
+
74
+ from commitguard.audit.models import Actor, AuditEventType
75
+ from commitguard.config.schema import CommitGuardConfig, PolicyOverride
76
+ from commitguard.config.sources import MandatoryPolicy
77
+ from commitguard.controlplane.errors import (
78
+ ConfirmationRequiredError,
79
+ ConflictError,
80
+ InputValidationError,
81
+ NotFoundError,
82
+ PolicyIntegrityError,
83
+ ReauthenticationRequiredError,
84
+ )
85
+ from commitguard.controlplane.results import clean_text
86
+ from commitguard.controlplane.rules import CATALOG_BY_ID
87
+ from commitguard.controlplane.views import (
88
+ OrganizationPolicyView,
89
+ OrganizationRef,
90
+ PolicyAuthor,
91
+ PolicyChange,
92
+ PolicyDiffEntry,
93
+ PolicyDiffView,
94
+ PolicyRuleView,
95
+ PolicyTargetView,
96
+ PolicyVersionView,
97
+ ScopedPolicyView,
98
+ ScopedRuleView,
99
+ )
100
+ from commitguard.core.decision import Action
101
+ from commitguard.core.result import Severity
102
+ from commitguard.github.storage import SqliteStateStore
103
+ from commitguard.notifications.deduplication import domain_key
104
+ from commitguard.notifications.models import NotificationEvent, NotificationType
105
+ from commitguard.notifications.outbox import emit
106
+ from commitguard.observability.metrics import (
107
+ POLICY_ROLLBACK_FAILURES,
108
+ POLICY_ROLLBACKS,
109
+ Metrics,
110
+ NullMetrics,
111
+ )
112
+ from commitguard.policies.defaults import DEFAULT_POLICIES
113
+ from commitguard.policies.governance import Enforcement, RuleRequirement
114
+ from commitguard.security.hashing import sha256_hex
115
+ from commitguard.services.audit import AuditService
116
+
117
+ MAX_REASON_CHARS = 500
118
+ #: Weakening changes require a sign-in no older than this.
119
+ REAUTHENTICATION_WINDOW = timedelta(minutes=15)
120
+ FLOOR_ACTIONS = (Action.WARN, Action.BLOCK)
121
+ #: Largest accepted policy document (resource exhaustion protection).
122
+ MAX_DOCUMENT_BYTES = 16_384
123
+
124
+
125
+ class PolicyTargetType(StrEnum):
126
+ ORGANIZATION = "organization"
127
+ GROUP = "group"
128
+ REPOSITORY = "repository"
129
+
130
+
131
+ @dataclass(frozen=True, slots=True)
132
+ class PolicyTarget:
133
+ type: PolicyTargetType
134
+ id: str = "" # "" for the organization; a group ID or a repository ID
135
+
136
+ @property
137
+ def scoped(self) -> bool:
138
+ return self.type is not PolicyTargetType.ORGANIZATION
139
+
140
+ @classmethod
141
+ def group(cls, group_id: str) -> "PolicyTarget":
142
+ return cls(PolicyTargetType.GROUP, group_id)
143
+
144
+ @classmethod
145
+ def repository(cls, repository_id: int) -> "PolicyTarget":
146
+ return cls(PolicyTargetType.REPOSITORY, str(int(repository_id)))
147
+
148
+
149
+ ORGANIZATION_TARGET = PolicyTarget(PolicyTargetType.ORGANIZATION)
150
+
151
+
152
+ @dataclass(frozen=True, slots=True)
153
+ class PolicyVersion:
154
+ account_id: int
155
+ version: int # 0: no policy has been saved for this target
156
+ floors: Mapping[str, Action] # mandatory entries
157
+ fingerprint: str | None
158
+ created_at: datetime | None
159
+ created_by_id: int | None
160
+ created_by_login: str | None
161
+ reason: str | None
162
+ kind: str = "change" # change | rollback
163
+ rollback_of: int | None = None # the version that was active when rolling back
164
+ restored_version: int | None = None # the version whose document was restored
165
+ document: str | None = None
166
+ defaults: Mapping[str, Action] = field(default_factory=dict) # default entries
167
+ target: PolicyTarget = ORGANIZATION_TARGET
168
+ draft_id: str | None = None
169
+ emergency: bool = False
170
+
171
+ @property
172
+ def requirements(self) -> dict[str, RuleRequirement]:
173
+ entries = {
174
+ rule: RuleRequirement(action=action, enforcement=Enforcement.DEFAULT)
175
+ for rule, action in self.defaults.items()
176
+ }
177
+ entries.update(
178
+ {rule: RuleRequirement(action=action) for rule, action in self.floors.items()}
179
+ )
180
+ return entries
181
+
182
+
183
+ @dataclass(frozen=True, slots=True)
184
+ class PublishedPolicy:
185
+ """Passed to publish hooks, inside the publishing transaction."""
186
+
187
+ account_id: int
188
+ target: PolicyTarget
189
+ version: int
190
+ previous_version: int
191
+ kind: str
192
+ now: datetime
193
+
194
+
195
+ type PublishHook = Callable[[sqlite3.Connection, PublishedPolicy], None]
196
+
197
+
198
+ def canonical_document(floors: Mapping[str, Action], defaults: Mapping[str, Action]) -> str:
199
+ overlap = sorted(set(floors) & set(defaults))
200
+ if overlap:
201
+ raise InputValidationError(
202
+ f"a rule is either mandatory or a default, not both: {', '.join(overlap)}",
203
+ field="defaults",
204
+ )
205
+ document: dict[str, object] = {k: floors[k].value for k in floors}
206
+ document.update({k: {"action": defaults[k].value, "enforcement": "default"} for k in defaults})
207
+ return json.dumps({k: document[k] for k in sorted(document)}, separators=(",", ":"))
208
+
209
+
210
+ # Phase 7 name, kept for callers that only deal with floors.
211
+ def _canonical(floors: Mapping[str, Action]) -> str:
212
+ return canonical_document(floors, {})
213
+
214
+
215
+ def parse_document(document: str) -> tuple[dict[str, Action], dict[str, Action]]:
216
+ """Split a stored document into (mandatory floors, defaults). Raises ``ValueError``."""
217
+ raw = json.loads(document)
218
+ if not isinstance(raw, dict):
219
+ raise ValueError("policy document must be an object")
220
+ floors: dict[str, Action] = {}
221
+ defaults: dict[str, Action] = {}
222
+ for key, value in raw.items():
223
+ if key not in DEFAULT_POLICIES:
224
+ raise ValueError("unknown policy ID")
225
+ if isinstance(value, str):
226
+ action = Action(value)
227
+ if action not in FLOOR_ACTIONS:
228
+ raise ValueError("a mandatory entry must be warn or block")
229
+ floors[key] = action
230
+ elif isinstance(value, dict) and set(value) == {"action", "enforcement"}:
231
+ if value["enforcement"] != "default" or not isinstance(value["action"], str):
232
+ raise ValueError("invalid default entry")
233
+ defaults[key] = Action(value["action"])
234
+ else:
235
+ raise ValueError("invalid policy entry")
236
+ return floors, defaults
237
+
238
+
239
+ def validate_floors(raw: object) -> dict[str, Action]:
240
+ if not isinstance(raw, Mapping):
241
+ raise InputValidationError("floors must be an object of policy IDs", field="floors")
242
+ floors: dict[str, Action] = {}
243
+ for key, value in raw.items():
244
+ if not isinstance(key, str) or key not in DEFAULT_POLICIES:
245
+ raise InputValidationError("unknown policy ID in floors", field="floors")
246
+ if value is None:
247
+ continue # no floor: the repository decides
248
+ if not isinstance(value, str) or value not in (a.value for a in FLOOR_ACTIONS):
249
+ raise InputValidationError(
250
+ f"the floor for {key} must be warn, block or null", field=f"floors.{key}"
251
+ )
252
+ floors[key] = Action(value)
253
+ return floors
254
+
255
+
256
+ def validate_defaults(raw: object) -> dict[str, Action]:
257
+ if not isinstance(raw, Mapping):
258
+ raise InputValidationError("defaults must be an object of policy IDs", field="defaults")
259
+ defaults: dict[str, Action] = {}
260
+ for key, value in raw.items():
261
+ if not isinstance(key, str) or key not in DEFAULT_POLICIES:
262
+ raise InputValidationError("unknown policy ID in defaults", field="defaults")
263
+ if value is None:
264
+ continue
265
+ if not isinstance(value, str) or value not in (a.value for a in Action):
266
+ raise InputValidationError(
267
+ f"the default for {key} must be allow, warn, block or null",
268
+ field=f"defaults.{key}",
269
+ )
270
+ defaults[key] = Action(value)
271
+ return defaults
272
+
273
+
274
+ def _label(change: PolicyChange) -> str:
275
+ suffix = " (default)" if change.enforcement == "default" else ""
276
+ return f"{change.policy_id}{suffix}"
277
+
278
+
279
+ def change_summary(changes: Sequence[PolicyChange]) -> str:
280
+ return "; ".join(
281
+ f"{_label(c)}: {c.old.value if c.old else 'repository'} -> "
282
+ f"{c.new.value if c.new else 'repository'}"
283
+ for c in changes
284
+ )
285
+
286
+
287
+ _change_summary = change_summary
288
+
289
+
290
+ def policy_changes(
291
+ old: Mapping[str, Action],
292
+ new: Mapping[str, Action],
293
+ old_defaults: Mapping[str, Action] | None = None,
294
+ new_defaults: Mapping[str, Action] | None = None,
295
+ ) -> list[PolicyChange]:
296
+ changes = []
297
+ for policy_id in sorted(set(old) | set(new)):
298
+ before, after = old.get(policy_id), new.get(policy_id)
299
+ if before == after:
300
+ continue
301
+ weakening = before is not None and (after is None or after.rank < before.rank)
302
+ changes.append(
303
+ PolicyChange(policy_id=policy_id, old=before, new=after, weakening=weakening)
304
+ )
305
+ old_defaults = old_defaults or {}
306
+ new_defaults = new_defaults or {}
307
+ for policy_id in sorted(set(old_defaults) | set(new_defaults)):
308
+ before, after = old_defaults.get(policy_id), new_defaults.get(policy_id)
309
+ if before == after:
310
+ continue
311
+ builtin = DEFAULT_POLICIES[policy_id].action
312
+ # Weaker when the baseline repositories fall back to is lower than before.
313
+ weakening = (after or builtin).rank < (before or builtin).rank
314
+ changes.append(
315
+ PolicyChange(
316
+ policy_id=policy_id,
317
+ old=before,
318
+ new=after,
319
+ weakening=weakening,
320
+ enforcement="default",
321
+ )
322
+ )
323
+ return changes
324
+
325
+
326
+ def policy_diff(
327
+ from_version: int,
328
+ old: Mapping[str, Action],
329
+ to_version: int,
330
+ new: Mapping[str, Action],
331
+ old_defaults: Mapping[str, Action] | None = None,
332
+ new_defaults: Mapping[str, Action] | None = None,
333
+ ) -> PolicyDiffView:
334
+ """A structured diff of two versions: added, changed, removed entries."""
335
+ added, changed, removed = [], [], []
336
+ for change in policy_changes(old, new, old_defaults, new_defaults):
337
+ entry = PolicyDiffEntry(
338
+ policy_id=change.policy_id,
339
+ old=change.old,
340
+ new=change.new,
341
+ weakening=change.weakening
342
+ if change.old is not None or change.enforcement == "default"
343
+ else False,
344
+ enforcement=change.enforcement,
345
+ )
346
+ if change.old is None:
347
+ added.append(entry)
348
+ elif change.new is None:
349
+ removed.append(
350
+ entry.model_copy(update={"weakening": True})
351
+ if change.enforcement == "mandatory"
352
+ else entry
353
+ )
354
+ else:
355
+ changed.append(entry)
356
+ return PolicyDiffView(
357
+ from_version=from_version,
358
+ to_version=to_version,
359
+ added=tuple(added),
360
+ changed=tuple(changed),
361
+ removed=tuple(removed),
362
+ weakening=any(e.weakening for e in (*added, *changed, *removed)),
363
+ )
364
+
365
+
366
+ # Static statements per storage table: no SQL is assembled from input.
367
+ _ORG_LATEST = (
368
+ "SELECT * FROM organization_policy_versions WHERE account_id = ? ORDER BY version DESC LIMIT 1"
369
+ )
370
+ _ORG_VERSION = "SELECT * FROM organization_policy_versions WHERE account_id = ? AND version = ?"
371
+ _ORG_PAGE = (
372
+ "SELECT * FROM organization_policy_versions WHERE account_id = ? "
373
+ "ORDER BY version DESC LIMIT ? OFFSET ?"
374
+ )
375
+ _ORG_MAX = "SELECT MAX(version) AS latest FROM organization_policy_versions WHERE account_id = ?"
376
+ _ORG_INSERT = (
377
+ "INSERT INTO organization_policy_versions (account_id, version, document, fingerprint, "
378
+ "created_at, created_by_id, created_by_login, reason, kind, rollback_of, restored_version, "
379
+ "draft_id, emergency) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
380
+ )
381
+ _SCOPED_LATEST = (
382
+ "SELECT * FROM scoped_policy_versions WHERE account_id = ? AND target_type = ? "
383
+ "AND target_id = ? ORDER BY version DESC LIMIT 1"
384
+ )
385
+ _SCOPED_VERSION = (
386
+ "SELECT * FROM scoped_policy_versions WHERE account_id = ? AND target_type = ? "
387
+ "AND target_id = ? AND version = ?"
388
+ )
389
+ _SCOPED_PAGE = (
390
+ "SELECT * FROM scoped_policy_versions WHERE account_id = ? AND target_type = ? "
391
+ "AND target_id = ? ORDER BY version DESC LIMIT ? OFFSET ?"
392
+ )
393
+ _SCOPED_MAX = (
394
+ "SELECT MAX(version) AS latest FROM scoped_policy_versions WHERE account_id = ? "
395
+ "AND target_type = ? AND target_id = ?"
396
+ )
397
+ _SCOPED_INSERT = (
398
+ "INSERT INTO scoped_policy_versions (account_id, target_type, target_id, version, document, "
399
+ "fingerprint, created_at, created_by_id, created_by_login, reason, kind, rollback_of, "
400
+ "restored_version, draft_id, emergency) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
401
+ )
402
+
403
+
404
+ def target_label(db: sqlite3.Connection, account_id: int, target: PolicyTarget) -> str | None:
405
+ """A display label for ``target`` within the account, or None if it is not the account's."""
406
+ if target.type is PolicyTargetType.ORGANIZATION:
407
+ return "Organization"
408
+ if target.type is PolicyTargetType.GROUP:
409
+ row = db.execute(
410
+ "SELECT name FROM repository_groups WHERE group_id = ? AND account_id = ? "
411
+ "AND archived_at IS NULL",
412
+ (target.id, int(account_id)),
413
+ ).fetchone()
414
+ return f"Group {row['name']}" if row else None
415
+ if not target.id.isdigit():
416
+ return None
417
+ row = db.execute(
418
+ "SELECT k.owner, k.name FROM known_repositories k "
419
+ "JOIN installations i ON i.installation_id = k.installation_id "
420
+ "WHERE i.account_id = ? AND k.repository_id = ? ORDER BY k.last_seen_at DESC LIMIT 1",
421
+ (int(account_id), int(target.id)),
422
+ ).fetchone()
423
+ return f"{row['owner']}/{row['name']}" if row else None
424
+
425
+
426
+ class OrganizationPolicyService:
427
+ """Versions, publication and rollback of policy for any target of an account."""
428
+
429
+ def __init__(
430
+ self,
431
+ store: SqliteStateStore,
432
+ audit: AuditService,
433
+ *,
434
+ service_policy: MandatoryPolicy | None = None,
435
+ metrics: Metrics | None = None,
436
+ now: Callable[[], datetime] = lambda: datetime.now(UTC),
437
+ ) -> None:
438
+ self._store = store
439
+ self._audit = audit
440
+ self._service_policy = service_policy
441
+ self._metrics: Metrics = metrics or NullMetrics()
442
+ self._now = now
443
+ self._publish_hooks: list[PublishHook] = []
444
+
445
+ @property
446
+ def service_policy(self) -> MandatoryPolicy | None:
447
+ return self._service_policy
448
+
449
+ def add_publish_hook(self, hook: PublishHook) -> None:
450
+ """Run ``hook`` inside every publishing transaction (e.g. cache invalidation)."""
451
+ self._publish_hooks.append(hook)
452
+
453
+ # -- storage --------------------------------------------------------- #
454
+ def _latest_rows(self, account_id: int, target: PolicyTarget) -> list[sqlite3.Row]:
455
+ if target.scoped:
456
+ return self._store.query(
457
+ _SCOPED_LATEST, (int(account_id), target.type.value, target.id)
458
+ )
459
+ return self._store.query(_ORG_LATEST, (int(account_id),))
460
+
461
+ def _version_rows(
462
+ self, account_id: int, target: PolicyTarget, version: int
463
+ ) -> list[sqlite3.Row]:
464
+ if target.scoped:
465
+ return self._store.query(
466
+ _SCOPED_VERSION, (int(account_id), target.type.value, target.id, int(version))
467
+ )
468
+ return self._store.query(_ORG_VERSION, (int(account_id), int(version)))
469
+
470
+ @staticmethod
471
+ def _max_version(db: sqlite3.Connection, account_id: int, target: PolicyTarget) -> int:
472
+ if target.scoped:
473
+ row = db.execute(
474
+ _SCOPED_MAX, (int(account_id), target.type.value, target.id)
475
+ ).fetchone()
476
+ else:
477
+ row = db.execute(_ORG_MAX, (int(account_id),)).fetchone()
478
+ return int(row["latest"] or 0)
479
+
480
+ @staticmethod
481
+ def _insert_version(
482
+ db: sqlite3.Connection,
483
+ *,
484
+ account_id: int,
485
+ target: PolicyTarget,
486
+ version: int,
487
+ document: str,
488
+ now: datetime,
489
+ actor: Actor,
490
+ reason: str | None,
491
+ kind: str = "change",
492
+ rollback_of: int | None = None,
493
+ restored_version: int | None = None,
494
+ draft_id: str | None = None,
495
+ emergency: bool = False,
496
+ ) -> None:
497
+ values = (
498
+ version,
499
+ document,
500
+ sha256_hex(document.encode("utf-8")),
501
+ now.timestamp(),
502
+ actor.id,
503
+ actor.login,
504
+ reason,
505
+ kind,
506
+ rollback_of,
507
+ restored_version,
508
+ draft_id,
509
+ 1 if emergency else 0,
510
+ )
511
+ if target.scoped:
512
+ db.execute(_SCOPED_INSERT, (int(account_id), target.type.value, target.id, *values))
513
+ else:
514
+ db.execute(_ORG_INSERT, (int(account_id), *values))
515
+
516
+ # -- reads ----------------------------------------------------------- #
517
+ def current(self, account_id: int, target: PolicyTarget = ORGANIZATION_TARGET) -> PolicyVersion:
518
+ rows = self._latest_rows(account_id, target)
519
+ if not rows:
520
+ return PolicyVersion(account_id, 0, {}, None, None, None, None, None, target=target)
521
+ return self._version(rows[0], target)
522
+
523
+ def version(
524
+ self, account_id: int, version: int, target: PolicyTarget = ORGANIZATION_TARGET
525
+ ) -> PolicyVersion | None:
526
+ rows = self._version_rows(account_id, target, version)
527
+ return self._version(rows[0], target) if rows else None
528
+
529
+ def versions(
530
+ self,
531
+ account_id: int,
532
+ *,
533
+ offset: int,
534
+ limit: int,
535
+ target: PolicyTarget = ORGANIZATION_TARGET,
536
+ ) -> list[PolicyVersionView]:
537
+ """Newest first. Each version is summarised against the version before it."""
538
+ if target.scoped:
539
+ rows = self._store.query(
540
+ _SCOPED_PAGE,
541
+ (int(account_id), target.type.value, target.id, int(limit) + 1, int(offset)),
542
+ )
543
+ else:
544
+ rows = self._store.query(_ORG_PAGE, (int(account_id), int(limit) + 1, int(offset)))
545
+ versions = [self._version(row, target) for row in rows]
546
+ latest = self.current(account_id, target).version
547
+ views = []
548
+ for index, version in enumerate(versions[:limit]):
549
+ if index + 1 < len(versions):
550
+ previous: PolicyVersion | None = versions[index + 1]
551
+ elif version.version > 1:
552
+ previous = self.version(account_id, version.version - 1, target)
553
+ else:
554
+ previous = None
555
+ views.append(
556
+ self.version_view(
557
+ version,
558
+ previous=previous.floors if previous else {},
559
+ previous_defaults=previous.defaults if previous else {},
560
+ active=version.version == latest,
561
+ )
562
+ )
563
+ return views
564
+
565
+ @staticmethod
566
+ def _version(row: sqlite3.Row, target: PolicyTarget = ORGANIZATION_TARGET) -> PolicyVersion:
567
+ try:
568
+ floors, defaults = parse_document(str(row["document"]))
569
+ except (ValueError, KeyError):
570
+ floors, defaults = {}, {}
571
+ keys = row.keys()
572
+ return PolicyVersion(
573
+ account_id=int(row["account_id"]),
574
+ version=int(row["version"]),
575
+ floors=floors,
576
+ fingerprint=str(row["fingerprint"]),
577
+ created_at=datetime.fromtimestamp(float(str(row["created_at"])), UTC),
578
+ created_by_id=int(str(row["created_by_id"]))
579
+ if row["created_by_id"] is not None
580
+ else None,
581
+ created_by_login=str(row["created_by_login"]) if row["created_by_login"] else None,
582
+ reason=str(row["reason"]) if row["reason"] else None,
583
+ kind=str(row["kind"] or "change"),
584
+ rollback_of=int(row["rollback_of"]) if row["rollback_of"] is not None else None,
585
+ restored_version=int(row["restored_version"])
586
+ if row["restored_version"] is not None
587
+ else None,
588
+ document=str(row["document"]),
589
+ defaults=defaults,
590
+ target=target,
591
+ draft_id=str(row["draft_id"]) if "draft_id" in keys and row["draft_id"] else None,
592
+ emergency=bool(row["emergency"]) if "emergency" in keys else False,
593
+ )
594
+
595
+ def version_view(
596
+ self,
597
+ version: PolicyVersion,
598
+ *,
599
+ previous: Mapping[str, Action] | None = None,
600
+ previous_defaults: Mapping[str, Action] | None = None,
601
+ active: bool | None = None,
602
+ ) -> PolicyVersionView:
603
+ if previous is None:
604
+ before = (
605
+ self.version(version.account_id, version.version - 1, version.target)
606
+ if version.version > 1
607
+ else None
608
+ )
609
+ previous = before.floors if before else {}
610
+ previous_defaults = before.defaults if before else {}
611
+ if active is None:
612
+ active = self.current(version.account_id, version.target).version == version.version
613
+ changes = policy_changes(
614
+ previous, version.floors, previous_defaults or {}, version.defaults
615
+ )
616
+ return PolicyVersionView(
617
+ version=version.version,
618
+ fingerprint=version.fingerprint or "",
619
+ floors=dict(version.floors),
620
+ created_at=version.created_at or datetime.fromtimestamp(0, UTC),
621
+ created_by=PolicyAuthor(id=version.created_by_id, login=version.created_by_login),
622
+ reason=version.reason,
623
+ status="active" if active else "archived",
624
+ kind=version.kind, # type: ignore[arg-type]
625
+ rollback_of=version.rollback_of,
626
+ restored_version=version.restored_version,
627
+ changes=tuple(changes),
628
+ summary=change_summary(changes) or "No policy changes",
629
+ defaults=dict(version.defaults),
630
+ draft_id=version.draft_id,
631
+ emergency=version.emergency,
632
+ )
633
+
634
+ def diff(
635
+ self,
636
+ account_id: int,
637
+ from_version: int,
638
+ to_version: int,
639
+ target: PolicyTarget = ORGANIZATION_TARGET,
640
+ ) -> PolicyDiffView | None:
641
+ """Diff two versions (version 0 is "no policy")."""
642
+ old = self.version(account_id, from_version, target) if from_version else None
643
+ new = self.version(account_id, to_version, target) if to_version else None
644
+ if (from_version and old is None) or (to_version and new is None):
645
+ return None
646
+ return policy_diff(
647
+ from_version,
648
+ old.floors if old else {},
649
+ to_version,
650
+ new.floors if new else {},
651
+ old.defaults if old else {},
652
+ new.defaults if new else {},
653
+ )
654
+
655
+ def service_floors(self) -> dict[str, Action]:
656
+ if self._service_policy is None:
657
+ return {}
658
+ return {
659
+ policy_id: override.action or DEFAULT_POLICIES[policy_id].action
660
+ for policy_id, override in self._service_policy.config.policies.items()
661
+ }
662
+
663
+ def view(self, organization: OrganizationRef, *, can_write: bool) -> OrganizationPolicyView:
664
+ current = self.current(organization.id)
665
+ service = self.service_floors()
666
+ rules = []
667
+ for policy_id, policy in DEFAULT_POLICIES.items():
668
+ org_floor = current.floors.get(policy_id)
669
+ service_floor = service.get(policy_id)
670
+ floors = [a for a in (org_floor, service_floor) if a is not None]
671
+ minimum = Action.most_restrictive(floors) if floors else None
672
+ org_default = current.defaults.get(policy_id)
673
+ source: Literal[
674
+ "built_in_default", "service_policy", "organization_policy", "organization_default"
675
+ ]
676
+ if minimum is None:
677
+ source = "organization_default" if org_default else "built_in_default"
678
+ elif (
679
+ service_floor is not None and service_floor.rank >= (org_floor or Action.ALLOW).rank
680
+ ):
681
+ source = "service_policy"
682
+ else:
683
+ source = "organization_policy"
684
+ entry = CATALOG_BY_ID.get(policy_id)
685
+ rules.append(
686
+ PolicyRuleView(
687
+ policy_id=policy_id,
688
+ name=entry.name if entry else policy_id,
689
+ description=policy.description,
690
+ default_action=policy.action,
691
+ service_floor=service_floor,
692
+ organization_floor=org_floor,
693
+ minimum_action=minimum,
694
+ repository_override="any" if minimum is None else "stricter_only",
695
+ source=source,
696
+ organization_default=org_default,
697
+ )
698
+ )
699
+ return OrganizationPolicyView(
700
+ organization=organization,
701
+ version=current.version,
702
+ fingerprint=current.fingerprint,
703
+ updated_at=current.created_at,
704
+ updated_by=PolicyAuthor(id=current.created_by_id, login=current.created_by_login)
705
+ if current.version
706
+ else None,
707
+ reason=current.reason,
708
+ service_policy=self._service_policy.description if self._service_policy else None,
709
+ rules=tuple(rules),
710
+ can_write=can_write,
711
+ )
712
+
713
+ def repository_targets(self, account_id: int) -> list[int]:
714
+ """Repositories of the account that have a dashboard repository policy."""
715
+ return [
716
+ int(r["target_id"])
717
+ for r in self._store.query(
718
+ "SELECT DISTINCT target_id FROM scoped_policy_versions WHERE account_id = ? "
719
+ "AND target_type = 'repository'",
720
+ (int(account_id),),
721
+ )
722
+ if str(r["target_id"]).isdigit()
723
+ ]
724
+
725
+ def target_label(self, account_id: int, target: PolicyTarget) -> str | None:
726
+ with self._store.transaction() as db:
727
+ return target_label(db, account_id, target)
728
+
729
+ def scoped_view(
730
+ self, organization: OrganizationRef, target: PolicyTarget, *, can_write: bool
731
+ ) -> ScopedPolicyView:
732
+ label = self.target_label(organization.id, target)
733
+ if label is None:
734
+ raise NotFoundError()
735
+ current = self.current(organization.id, target)
736
+ rules = tuple(
737
+ ScopedRuleView(
738
+ policy_id=policy_id,
739
+ name=CATALOG_BY_ID[policy_id].name if policy_id in CATALOG_BY_ID else policy_id,
740
+ mandatory=current.floors.get(policy_id),
741
+ default=current.defaults.get(policy_id),
742
+ )
743
+ for policy_id in DEFAULT_POLICIES
744
+ )
745
+ return ScopedPolicyView(
746
+ organization=organization,
747
+ target=PolicyTargetView(type=target.type.value, id=target.id, label=label),
748
+ version=current.version,
749
+ fingerprint=current.fingerprint,
750
+ updated_at=current.created_at,
751
+ updated_by=PolicyAuthor(id=current.created_by_id, login=current.created_by_login)
752
+ if current.version
753
+ else None,
754
+ reason=current.reason,
755
+ rules=rules,
756
+ can_write=can_write,
757
+ )
758
+
759
+ # -- writes ---------------------------------------------------------- #
760
+ def update(
761
+ self,
762
+ *,
763
+ account_id: int,
764
+ actor: Actor,
765
+ authenticated_at: datetime,
766
+ expected_version: int,
767
+ floors: Mapping[str, Action],
768
+ reason: str | None,
769
+ confirm_weakening: bool,
770
+ defaults: Mapping[str, Action] | None = None,
771
+ target: PolicyTarget = ORGANIZATION_TARGET,
772
+ draft_id: str | None = None,
773
+ emergency: bool = False,
774
+ hooks: Sequence[PublishHook] = (),
775
+ ) -> tuple[PolicyVersion, list[PolicyChange]]:
776
+ """Publish a new version of ``target``'s policy.
777
+
778
+ ``defaults=None`` keeps the current default entries (the Phase 6 API only
779
+ sends floors). The caller has already applied the organization's approval
780
+ requirements: this method publishes.
781
+ """
782
+ now = self._now()
783
+ reason_text = (
784
+ clean_text(reason.strip(), MAX_REASON_CHARS) if reason and reason.strip() else None
785
+ )
786
+ current = self.current(account_id, target)
787
+ if current.version != expected_version:
788
+ raise ConflictError(
789
+ f"The policy was changed by someone else (now version {current.version}). "
790
+ "Reload to see the latest version before saving."
791
+ )
792
+ if defaults is None:
793
+ # Floors-only callers keep the defaults; a rule that became mandatory
794
+ # is no longer also a default.
795
+ new_defaults = {k: v for k, v in current.defaults.items() if k not in floors}
796
+ else:
797
+ new_defaults = dict(defaults)
798
+ changes = policy_changes(current.floors, floors, current.defaults, new_defaults)
799
+ if not changes:
800
+ raise InputValidationError("The new policy is identical to the current version.")
801
+ weakening = [c for c in changes if c.weakening]
802
+ if weakening and not confirm_weakening:
803
+ raise ConfirmationRequiredError(
804
+ "This change weakens enforcement and must be confirmed explicitly."
805
+ )
806
+ if weakening and now - authenticated_at > REAUTHENTICATION_WINDOW:
807
+ raise ReauthenticationRequiredError()
808
+ if (weakening or emergency) and reason_text is None:
809
+ raise InputValidationError(
810
+ "A reason is required when weakening enforcement or publishing in an emergency.",
811
+ field="reason",
812
+ )
813
+ document = canonical_document(floors, new_defaults)
814
+ if len(document.encode("utf-8")) > MAX_DOCUMENT_BYTES:
815
+ raise InputValidationError("The policy document is too large.")
816
+ summary = change_summary(changes)
817
+ with self._store.transaction() as db:
818
+ label = target_label(db, account_id, target)
819
+ if label is None:
820
+ raise NotFoundError()
821
+ latest = self._max_version(db, account_id, target)
822
+ if latest != expected_version:
823
+ raise ConflictError(
824
+ f"The policy was changed by someone else (now version {latest}). "
825
+ "Reload to see the latest version before saving."
826
+ )
827
+ self._insert_version(
828
+ db,
829
+ account_id=account_id,
830
+ target=target,
831
+ version=latest + 1,
832
+ document=document,
833
+ now=now,
834
+ actor=actor,
835
+ reason=reason_text,
836
+ draft_id=draft_id,
837
+ emergency=emergency,
838
+ )
839
+ target_data = self._target_data(target, label)
840
+ stored = [
841
+ self._store.insert_audit_event(
842
+ db,
843
+ self._audit.build(
844
+ AuditEventType.POLICY_PUBLISHED
845
+ if target.scoped
846
+ else AuditEventType.ORGANIZATION_POLICY_CHANGED,
847
+ actor=actor,
848
+ account_id=account_id,
849
+ old_version=latest,
850
+ new_version=latest + 1,
851
+ changes=summary,
852
+ weakening=bool(weakening),
853
+ reason=reason_text,
854
+ extra={**target_data, **({"draft": draft_id} if draft_id else {})},
855
+ ),
856
+ )
857
+ ]
858
+ title_label = "Organization policy" if not target.scoped else f"{label} policy"
859
+ emit(
860
+ db,
861
+ self._policy_notification(
862
+ NotificationType.POLICY_CHANGED,
863
+ account_id=account_id,
864
+ new_version=latest + 1,
865
+ title=f"{title_label} changed: v{latest} → v{latest + 1}",
866
+ body=(
867
+ f"{actor.login or 'An administrator'} published "
868
+ f"{title_label.lower()} v{latest + 1}. Changes: {summary}."
869
+ + (" This change weakens enforcement." if weakening else "")
870
+ + (f" Reason: {reason_text}" if reason_text else "")
871
+ ),
872
+ weakening=bool(weakening),
873
+ target=target,
874
+ metadata={
875
+ "previous_version": latest,
876
+ "new_version": latest + 1,
877
+ "changed_by": actor.login,
878
+ "changes": summary,
879
+ "weakening": bool(weakening),
880
+ "reason": reason_text,
881
+ **target_data,
882
+ },
883
+ ),
884
+ now,
885
+ )
886
+ if emergency:
887
+ stored.append(
888
+ self._store.insert_audit_event(
889
+ db,
890
+ self._audit.build(
891
+ AuditEventType.POLICY_EMERGENCY_PUBLISHED,
892
+ actor=actor,
893
+ account_id=account_id,
894
+ new_version=latest + 1,
895
+ changes=summary,
896
+ reason=reason_text,
897
+ extra=target_data,
898
+ ),
899
+ )
900
+ )
901
+ emit(
902
+ db,
903
+ NotificationEvent(
904
+ type=NotificationType.POLICY_EMERGENCY_PUBLISHED,
905
+ account_id=account_id,
906
+ severity=Severity.CRITICAL,
907
+ resource_type="policy",
908
+ resource_id=str(account_id),
909
+ dedup_key=domain_key(
910
+ NotificationType.POLICY_EMERGENCY_PUBLISHED,
911
+ account_id,
912
+ target.type.value,
913
+ target.id or "-",
914
+ latest + 1,
915
+ ),
916
+ title=f"Emergency policy publication: {title_label} v{latest + 1}",
917
+ body=(
918
+ f"{actor.login or 'An administrator'} published {title_label.lower()} "
919
+ f"v{latest + 1} without the approval workflow. Changes: {summary}. "
920
+ f"Reason: {reason_text}"
921
+ ),
922
+ metadata={"new_version": latest + 1, "changes": summary, **target_data},
923
+ ),
924
+ now,
925
+ )
926
+ published = PublishedPolicy(account_id, target, latest + 1, latest, "change", now)
927
+ for hook in (*self._publish_hooks, *hooks):
928
+ hook(db, published)
929
+ for event in stored:
930
+ self._audit.log_stored(event)
931
+ updated = self.version(account_id, latest + 1, target)
932
+ assert updated is not None # noqa: S101 - written in the transaction above
933
+ return updated, changes
934
+
935
+ @staticmethod
936
+ def _target_data(target: PolicyTarget, label: str) -> dict[str, str | int | bool | None]:
937
+ if not target.scoped:
938
+ return {}
939
+ return {"target_type": target.type.value, "target_id": target.id, "target": label}
940
+
941
+ @staticmethod
942
+ def _policy_notification(
943
+ notification_type: NotificationType,
944
+ *,
945
+ account_id: int,
946
+ new_version: int,
947
+ title: str,
948
+ body: str,
949
+ weakening: bool,
950
+ metadata: dict[str, str | int | bool | None],
951
+ target: PolicyTarget = ORGANIZATION_TARGET,
952
+ ) -> NotificationEvent:
953
+ key = (
954
+ domain_key(notification_type, account_id, target.type.value, target.id, new_version)
955
+ if target.scoped
956
+ else domain_key(notification_type, account_id, new_version)
957
+ )
958
+ return NotificationEvent(
959
+ type=notification_type,
960
+ account_id=account_id,
961
+ severity=Severity.CRITICAL if weakening else Severity.HIGH,
962
+ resource_type="policy",
963
+ resource_id=str(account_id),
964
+ dedup_key=key,
965
+ title=title,
966
+ body=body,
967
+ metadata=metadata,
968
+ )
969
+
970
+ def rollback(
971
+ self,
972
+ *,
973
+ account_id: int,
974
+ actor: Actor,
975
+ authenticated_at: datetime,
976
+ target_version: int,
977
+ expected_current_version: int,
978
+ reason: str | None,
979
+ confirm: bool,
980
+ target: PolicyTarget = ORGANIZATION_TARGET,
981
+ hooks: Sequence[PublishHook] = (),
982
+ ) -> tuple[PolicyVersion, PolicyDiffView]:
983
+ """Publish a new version restoring ``target_version``'s document. Atomic."""
984
+ try:
985
+ return self._rollback(
986
+ account_id=account_id,
987
+ actor=actor,
988
+ authenticated_at=authenticated_at,
989
+ target_version=target_version,
990
+ expected_current_version=expected_current_version,
991
+ reason=reason,
992
+ confirm=confirm,
993
+ target=target,
994
+ hooks=hooks,
995
+ )
996
+ except Exception:
997
+ self._metrics.increment(POLICY_ROLLBACK_FAILURES)
998
+ raise
999
+
1000
+ def _rollback(
1001
+ self,
1002
+ *,
1003
+ account_id: int,
1004
+ actor: Actor,
1005
+ authenticated_at: datetime,
1006
+ target_version: int,
1007
+ expected_current_version: int,
1008
+ reason: str | None,
1009
+ confirm: bool,
1010
+ target: PolicyTarget,
1011
+ hooks: Sequence[PublishHook],
1012
+ ) -> tuple[PolicyVersion, PolicyDiffView]:
1013
+ now = self._now()
1014
+ reason_text = (
1015
+ clean_text(reason.strip(), MAX_REASON_CHARS) if reason and reason.strip() else None
1016
+ )
1017
+ if reason_text is None:
1018
+ raise InputValidationError("A reason is required to roll back policy.", field="reason")
1019
+ current = self.current(account_id, target)
1020
+ if current.version != expected_current_version:
1021
+ raise ConflictError(
1022
+ f"The policy was changed by someone else (now version {current.version}). "
1023
+ "Reload to see the latest version before rolling back."
1024
+ )
1025
+ if not 1 <= target_version < current.version:
1026
+ raise InputValidationError(
1027
+ "The target must be an earlier version of this policy.",
1028
+ field="target_version",
1029
+ )
1030
+ restored = self.version(account_id, target_version, target)
1031
+ if restored is None:
1032
+ raise InputValidationError(
1033
+ f"Version {target_version} does not exist.", field="target_version"
1034
+ )
1035
+ self._verify_integrity(restored)
1036
+ diff = policy_diff(
1037
+ current.version,
1038
+ current.floors,
1039
+ target_version,
1040
+ restored.floors,
1041
+ current.defaults,
1042
+ restored.defaults,
1043
+ )
1044
+ if not (diff.added or diff.changed or diff.removed):
1045
+ raise InputValidationError(
1046
+ f"Version {target_version} is identical to the active version.",
1047
+ field="target_version",
1048
+ )
1049
+ if not confirm:
1050
+ raise ConfirmationRequiredError(
1051
+ "Rolling back changes the effective security policy and must be confirmed."
1052
+ )
1053
+ if diff.weakening and now - authenticated_at > REAUTHENTICATION_WINDOW:
1054
+ raise ReauthenticationRequiredError()
1055
+ assert restored.document is not None # noqa: S101 - read from the database
1056
+ changes = policy_changes(
1057
+ current.floors, restored.floors, current.defaults, restored.defaults
1058
+ )
1059
+ summary = change_summary(changes)
1060
+ with self._store.transaction() as db:
1061
+ label = target_label(db, account_id, target)
1062
+ if label is None:
1063
+ raise NotFoundError()
1064
+ latest = self._max_version(db, account_id, target)
1065
+ if latest != expected_current_version:
1066
+ raise ConflictError(
1067
+ f"The policy was changed by someone else (now version {latest}). "
1068
+ "Reload to see the latest version before rolling back."
1069
+ )
1070
+ new_version = latest + 1
1071
+ self._insert_version(
1072
+ db,
1073
+ account_id=account_id,
1074
+ target=target,
1075
+ version=new_version,
1076
+ document=restored.document,
1077
+ now=now,
1078
+ actor=actor,
1079
+ reason=reason_text,
1080
+ kind="rollback",
1081
+ rollback_of=latest,
1082
+ restored_version=target_version,
1083
+ )
1084
+ target_data = self._target_data(target, label)
1085
+ stored = self._store.insert_audit_event(
1086
+ db,
1087
+ self._audit.build(
1088
+ AuditEventType.POLICY_ROLLED_BACK
1089
+ if target.scoped
1090
+ else AuditEventType.ORGANIZATION_POLICY_ROLLED_BACK,
1091
+ actor=actor,
1092
+ account_id=account_id,
1093
+ previous_version=latest,
1094
+ target_version=target_version,
1095
+ new_version=new_version,
1096
+ changes=summary,
1097
+ weakening=diff.weakening,
1098
+ reason=reason_text,
1099
+ extra=target_data,
1100
+ ),
1101
+ )
1102
+ title_label = "Organization policy" if not target.scoped else f"{label} policy"
1103
+ emit(
1104
+ db,
1105
+ self._policy_notification(
1106
+ NotificationType.POLICY_ROLLED_BACK,
1107
+ account_id=account_id,
1108
+ new_version=new_version,
1109
+ title=(
1110
+ f"{title_label} rolled back: v{latest} → v{new_version} "
1111
+ f"(restores v{target_version})"
1112
+ ),
1113
+ body=(
1114
+ f"{actor.login or 'An administrator'} rolled back the "
1115
+ f"{title_label.lower()}. Previous: v{latest}. Restored: v{target_version}. "
1116
+ f"Effective: v{new_version}. Changes: {summary}. Reason: {reason_text}"
1117
+ ),
1118
+ weakening=diff.weakening,
1119
+ target=target,
1120
+ metadata={
1121
+ "previous_version": latest,
1122
+ "target_version": target_version,
1123
+ "new_version": new_version,
1124
+ "changed_by": actor.login,
1125
+ "changes": summary,
1126
+ "weakening": diff.weakening,
1127
+ "reason": reason_text,
1128
+ **target_data,
1129
+ },
1130
+ ),
1131
+ now,
1132
+ )
1133
+ published = PublishedPolicy(account_id, target, new_version, latest, "rollback", now)
1134
+ for hook in (*self._publish_hooks, *hooks):
1135
+ hook(db, published)
1136
+ self._audit.log_stored(stored)
1137
+ self._metrics.increment(POLICY_ROLLBACKS)
1138
+ created = self.version(account_id, new_version, target)
1139
+ assert created is not None # noqa: S101 - written in the transaction above
1140
+ return created, diff
1141
+
1142
+ @staticmethod
1143
+ def _verify_integrity(version: PolicyVersion) -> None:
1144
+ document = version.document or ""
1145
+ if sha256_hex(document.encode("utf-8")) != version.fingerprint:
1146
+ raise PolicyIntegrityError(
1147
+ f"Version {version.version} failed its integrity check and cannot be restored."
1148
+ )
1149
+ try:
1150
+ floors, defaults = parse_document(document)
1151
+ validate_floors({k: v.value for k, v in floors.items()})
1152
+ canonical = canonical_document(floors, defaults)
1153
+ except (ValueError, InputValidationError):
1154
+ raise PolicyIntegrityError(
1155
+ f"Version {version.version} is not a valid policy and cannot be restored."
1156
+ ) from None
1157
+ if canonical != document:
1158
+ raise PolicyIntegrityError(
1159
+ f"Version {version.version} is not in canonical form and cannot be restored."
1160
+ )
1161
+
1162
+ # -- enforcement ----------------------------------------------------- #
1163
+ def mandatory_for_installation(
1164
+ self, installation_id: int
1165
+ ) -> tuple[MandatoryPolicy | None, int | None]:
1166
+ """The combined floor (service + organisation) for an installation.
1167
+
1168
+ Kept for adapters without organization governance; the GitHub App resolves
1169
+ the complete governance inputs per repository instead
1170
+ (:class:`commitguard.governance.resolver.GovernanceResolver`).
1171
+ """
1172
+ rows = self._store.query(
1173
+ "SELECT account_id FROM installations WHERE installation_id = ?",
1174
+ (int(installation_id),),
1175
+ )
1176
+ organization = self.current(int(rows[0]["account_id"])) if rows else None
1177
+ org_floors = dict(organization.floors) if organization else {}
1178
+ service = self.service_floors()
1179
+ combined = {
1180
+ policy_id: Action.most_restrictive(
1181
+ [a for a in (org_floors.get(policy_id), service.get(policy_id)) if a is not None]
1182
+ )
1183
+ for policy_id in sorted(set(org_floors) | set(service))
1184
+ }
1185
+ version = organization.version if organization and organization.version else None
1186
+ if not combined:
1187
+ return None, version
1188
+ parts = []
1189
+ if self._service_policy is not None:
1190
+ parts.append(f"service policy {self._service_policy.description}")
1191
+ if version:
1192
+ parts.append(f"organization policy v{version}")
1193
+ config = CommitGuardConfig(
1194
+ version=1,
1195
+ policies={pid: PolicyOverride(action=action) for pid, action in combined.items()},
1196
+ )
1197
+ return (
1198
+ MandatoryPolicy(
1199
+ config=config,
1200
+ description=" + ".join(parts),
1201
+ fingerprint=sha256_hex(_canonical(combined).encode("utf-8")),
1202
+ ),
1203
+ version,
1204
+ )