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,734 @@
1
+ """Policy workflow: draft -> simulate -> review -> approve -> publish.
2
+
3
+ ::
4
+
5
+ draft ──submit──► pending_approval ──approve──► approved ──publish──► published
6
+ │ │ │ │
7
+ │ └──reject──► rejected └──edit──► draft └─ immutable
8
+ └──cancel──► cancelled version
9
+
10
+ A draft holds a *proposed* document for one policy target (organization,
11
+ repository group or repository). Nothing about it is enforced: the effective
12
+ policy changes only when a draft is published, which writes an immutable
13
+ version through :class:`~commitguard.controlplane.policies.OrganizationPolicyService`
14
+ (the Phase 6/7 code path, with its confirmation, re-authentication, conflict
15
+ and rollback rules).
16
+
17
+ Whether approval is required is an organization setting. With
18
+ ``require_policy_approval`` off, an administrator with ``policies:publish`` can
19
+ publish a draft directly (and the Phase 6 "save policy" endpoint keeps working).
20
+ With it on, a draft must be approved first, and direct saves are refused with
21
+ ``APPROVAL_REQUIRED``.
22
+
23
+ Separation of duties (``require_separate_approver``, on by default): the person
24
+ who created or submitted a draft cannot approve it. Approval binds to the
25
+ draft's document fingerprint - editing an approved draft cancels the approval,
26
+ so no one can get one document approved and publish another.
27
+
28
+ Emergency publication (``policies:emergency``, owners) skips the approval
29
+ workflow. It requires a reason, is recorded on the version itself, and produces
30
+ a critical audit event and notification: it is never silent.
31
+ """
32
+
33
+ import sqlite3
34
+ from collections.abc import Callable, Mapping
35
+ from datetime import UTC, datetime
36
+ from typing import Any
37
+
38
+ from pydantic import BaseModel, ConfigDict
39
+
40
+ from commitguard.audit.models import Actor, AuditEventType
41
+ from commitguard.controlplane.access import Permission, Principal
42
+ from commitguard.controlplane.errors import (
43
+ ApprovalRequiredError,
44
+ ConflictError,
45
+ InputValidationError,
46
+ NotFoundError,
47
+ PermissionDeniedError,
48
+ )
49
+ from commitguard.controlplane.policies import (
50
+ MAX_DOCUMENT_BYTES,
51
+ ORGANIZATION_TARGET,
52
+ OrganizationPolicyService,
53
+ PolicyTarget,
54
+ PolicyTargetType,
55
+ PublishedPolicy,
56
+ canonical_document,
57
+ change_summary,
58
+ parse_document,
59
+ policy_changes,
60
+ policy_diff,
61
+ target_label,
62
+ validate_defaults,
63
+ validate_floors,
64
+ )
65
+ from commitguard.controlplane.views import PolicyChange, PolicyDiffView, PolicyTargetView
66
+ from commitguard.core.decision import Action
67
+ from commitguard.core.result import Severity
68
+ from commitguard.github.storage import SqliteStateStore
69
+ from commitguard.governance.common import (
70
+ MAX_NAME_CHARS,
71
+ MAX_REASON_CHARS,
72
+ dt,
73
+ is_hex_id,
74
+ new_id,
75
+ req_dt,
76
+ require,
77
+ text,
78
+ ts,
79
+ visible_repository_ids,
80
+ )
81
+ from commitguard.governance.settings import load_settings
82
+ from commitguard.notifications.deduplication import domain_key
83
+ from commitguard.notifications.models import NotificationEvent, NotificationType
84
+ from commitguard.notifications.outbox import emit
85
+ from commitguard.security.hashing import sha256_hex
86
+ from commitguard.services.audit import AuditService
87
+
88
+ DRAFT_STATES = ("draft", "pending_approval", "approved", "rejected", "cancelled", "published")
89
+ OPEN_DRAFT_STATES = ("draft", "pending_approval", "approved")
90
+ MAX_OPEN_DRAFTS = 50
91
+
92
+
93
+ class PolicyApprovalView(BaseModel):
94
+ model_config = ConfigDict(frozen=True, extra="forbid")
95
+
96
+ id: str
97
+ status: str
98
+ requested_by: str | None
99
+ requested_at: datetime
100
+ decided_by: str | None
101
+ decided_at: datetime | None
102
+ reason: str | None
103
+
104
+
105
+ class PolicyDraftView(BaseModel):
106
+ model_config = ConfigDict(frozen=True, extra="forbid")
107
+
108
+ id: str
109
+ organization_id: int
110
+ target: PolicyTargetView
111
+ title: str
112
+ reason: str | None
113
+ state: str
114
+ revision: int
115
+ base_version: int
116
+ current_version: int # the target's published version right now
117
+ floors: dict[str, Action]
118
+ defaults: dict[str, Action]
119
+ changes: tuple[PolicyChange, ...]
120
+ diff: PolicyDiffView
121
+ weakening: bool
122
+ rebase_required: bool
123
+ requires_approval: bool
124
+ created_by: str | None
125
+ created_at: datetime
126
+ updated_at: datetime
127
+ submitted_by: str | None
128
+ submitted_at: datetime | None
129
+ published_version: int | None
130
+ published_at: datetime | None
131
+ published_by: str | None
132
+ emergency: bool
133
+ rollout_id: str | None
134
+ approvals: tuple[PolicyApprovalView, ...]
135
+ can_edit: bool
136
+ can_submit: bool
137
+ can_approve: bool
138
+ can_publish: bool
139
+ can_cancel: bool
140
+ can_emergency_publish: bool
141
+
142
+
143
+ def parse_target(target_type: object, target_id: object) -> PolicyTarget:
144
+ if not isinstance(target_type, str) or target_type not in {t.value for t in PolicyTargetType}:
145
+ raise InputValidationError(
146
+ "target_type must be organization, group or repository", field="target_type"
147
+ )
148
+ kind = PolicyTargetType(target_type)
149
+ if kind is PolicyTargetType.ORGANIZATION:
150
+ if target_id not in (None, ""):
151
+ raise InputValidationError("an organization policy has no target_id", field="target_id")
152
+ return ORGANIZATION_TARGET
153
+ if kind is PolicyTargetType.GROUP:
154
+ if not is_hex_id(target_id):
155
+ raise InputValidationError("target_id must be a group ID", field="target_id")
156
+ assert isinstance(target_id, str) # noqa: S101 - checked above
157
+ return PolicyTarget.group(target_id)
158
+ if isinstance(target_id, bool) or not isinstance(target_id, int):
159
+ if isinstance(target_id, str) and target_id.isdigit() and len(target_id) < 17:
160
+ target_id = int(target_id)
161
+ else:
162
+ raise InputValidationError("target_id must be a repository ID", field="target_id")
163
+ return PolicyTarget.repository(int(target_id))
164
+
165
+
166
+ def parse_rules(body: Mapping[str, Any]) -> tuple[dict[str, Action], dict[str, Action]]:
167
+ """``{"floors": {...}, "defaults": {...}}`` -> mandatory and default entries."""
168
+ floors = validate_floors(body.get("floors") or {})
169
+ defaults = validate_defaults(body.get("defaults") or {})
170
+ overlap = sorted(set(floors) & set(defaults))
171
+ if overlap:
172
+ raise InputValidationError(
173
+ f"a rule is either mandatory or a default, not both: {', '.join(overlap)}",
174
+ field="defaults",
175
+ )
176
+ if not floors and not defaults:
177
+ raise InputValidationError("a policy must set at least one rule", field="floors")
178
+ return floors, defaults
179
+
180
+
181
+ class PolicyWorkflowService:
182
+ def __init__(
183
+ self,
184
+ store: SqliteStateStore,
185
+ audit: AuditService,
186
+ policies: OrganizationPolicyService,
187
+ *,
188
+ now: Callable[[], datetime] = lambda: datetime.now(UTC),
189
+ ) -> None:
190
+ self._store = store
191
+ self._audit = audit
192
+ self._policies = policies
193
+ self._now = now
194
+
195
+ # -- helpers ---------------------------------------------------------- #
196
+ def _target_of(self, row: sqlite3.Row) -> PolicyTarget:
197
+ return PolicyTarget(PolicyTargetType(row["target_type"]), str(row["target_id"]))
198
+
199
+ def _label(self, account_id: int, target: PolicyTarget) -> str:
200
+ with self._store.transaction() as db:
201
+ label = target_label(db, account_id, target)
202
+ if label is None:
203
+ raise NotFoundError()
204
+ return label
205
+
206
+ def _require_target_access(
207
+ self, principal: Principal, account_id: int, target: PolicyTarget
208
+ ) -> str:
209
+ label = self._label(account_id, target)
210
+ if target.type is PolicyTargetType.REPOSITORY:
211
+ visible = visible_repository_ids(self._store, principal, account_id)
212
+ if int(target.id) not in visible:
213
+ raise NotFoundError()
214
+ return label
215
+
216
+ def _draft_row(self, draft_id: str) -> sqlite3.Row:
217
+ if not is_hex_id(draft_id):
218
+ raise NotFoundError()
219
+ rows = self._store.query("SELECT * FROM policy_drafts WHERE draft_id = ?", (draft_id,))
220
+ if not rows:
221
+ raise NotFoundError()
222
+ return rows[0]
223
+
224
+ def _authorized_draft(
225
+ self, principal: Principal, draft_id: str, permission: Permission
226
+ ) -> sqlite3.Row:
227
+ row = self._draft_row(draft_id)
228
+ account_id = int(row["account_id"])
229
+ require(principal, Permission.POLICIES_READ, account_id)
230
+ self._require_target_access(principal, account_id, self._target_of(row))
231
+ if not principal.can(permission, account_id):
232
+ raise PermissionDeniedError()
233
+ return row
234
+
235
+ def authorize(self, principal: Principal, draft_id: str, permission: Permission) -> int:
236
+ """The draft's organization after checking ``permission`` (404 across tenants)."""
237
+ return int(self._authorized_draft(principal, draft_id, permission)["account_id"])
238
+
239
+ def _view(self, row: sqlite3.Row, principal: Principal) -> PolicyDraftView:
240
+ account_id = int(row["account_id"])
241
+ target = self._target_of(row)
242
+ floors, defaults = parse_document(str(row["document"]))
243
+ current = self._policies.current(account_id, target)
244
+ state = str(row["state"])
245
+ published_version = row["published_version"]
246
+ if state == "published" and published_version is not None:
247
+ # What this draft changed when it was published, not against today's policy.
248
+ before = (
249
+ self._policies.version(account_id, int(published_version) - 1, target)
250
+ if int(published_version) > 1
251
+ else None
252
+ )
253
+ old_version, new_version = int(published_version) - 1, int(published_version)
254
+ old_floors = before.floors if before else {}
255
+ old_defaults = before.defaults if before else {}
256
+ else:
257
+ old_version, new_version = current.version, current.version + 1
258
+ old_floors, old_defaults = current.floors, current.defaults
259
+ changes = policy_changes(old_floors, floors, old_defaults, defaults)
260
+ diff = policy_diff(old_version, old_floors, new_version, floors, old_defaults, defaults)
261
+ settings = load_settings(self._store, account_id).settings
262
+ approvals = tuple(
263
+ PolicyApprovalView(
264
+ id=a["approval_id"],
265
+ status=a["status"],
266
+ requested_by=a["requested_by_login"],
267
+ requested_at=req_dt(a["requested_at"]),
268
+ decided_by=a["decided_by_login"],
269
+ decided_at=dt(a["decided_at"]),
270
+ reason=a["reason"],
271
+ )
272
+ for a in self._store.query(
273
+ "SELECT * FROM policy_approvals WHERE draft_id = ? ORDER BY requested_at DESC",
274
+ (row["draft_id"],),
275
+ )
276
+ )
277
+ author = row["created_by_id"] in (principal.user_id,) or row["submitted_by_id"] in (
278
+ principal.user_id,
279
+ )
280
+ can_approve = (
281
+ state == "pending_approval"
282
+ and principal.can(Permission.POLICIES_APPROVE, account_id)
283
+ and not (settings.require_separate_approver and author)
284
+ )
285
+ return PolicyDraftView(
286
+ id=row["draft_id"],
287
+ organization_id=account_id,
288
+ target=PolicyTargetView(
289
+ type=target.type.value,
290
+ id=target.id,
291
+ label=self._label(account_id, target),
292
+ ),
293
+ title=row["title"],
294
+ reason=row["reason"],
295
+ state=state,
296
+ revision=int(row["revision"]),
297
+ base_version=int(row["base_version"]),
298
+ current_version=current.version,
299
+ floors=floors,
300
+ defaults=defaults,
301
+ changes=tuple(changes),
302
+ diff=diff,
303
+ weakening=any(c.weakening for c in changes),
304
+ rebase_required=state != "published" and current.version != int(row["base_version"]),
305
+ requires_approval=settings.require_policy_approval,
306
+ created_by=row["created_by_login"],
307
+ created_at=req_dt(row["created_at"]),
308
+ updated_at=req_dt(row["updated_at"]),
309
+ submitted_by=row["submitted_by_login"],
310
+ submitted_at=dt(row["submitted_at"]),
311
+ published_version=row["published_version"],
312
+ published_at=dt(row["published_at"]),
313
+ published_by=row["published_by_login"],
314
+ emergency=bool(row["emergency"]),
315
+ rollout_id=row["rollout_id"],
316
+ approvals=approvals,
317
+ can_edit=state in ("draft", "approved", "rejected")
318
+ and principal.can(Permission.POLICIES_WRITE, account_id),
319
+ can_submit=state == "draft" and principal.can(Permission.POLICIES_WRITE, account_id),
320
+ can_approve=can_approve,
321
+ can_publish=state in ("draft", "approved")
322
+ and principal.can(Permission.POLICIES_PUBLISH, account_id)
323
+ and (state == "approved" or not settings.require_policy_approval),
324
+ can_cancel=state in ("draft", "pending_approval", "approved", "rejected")
325
+ and principal.can(Permission.POLICIES_WRITE, account_id),
326
+ can_emergency_publish=state in ("draft", "pending_approval", "approved")
327
+ and principal.can(Permission.POLICIES_EMERGENCY, account_id),
328
+ )
329
+
330
+ # -- reads ------------------------------------------------------------ #
331
+ def list_drafts(
332
+ self, principal: Principal, account_id: int, *, state: str | None = None
333
+ ) -> list[PolicyDraftView]:
334
+ require(principal, Permission.POLICIES_READ, account_id)
335
+ if state is not None and state not in DRAFT_STATES:
336
+ raise InputValidationError("unknown draft state", field="state")
337
+ sql = "SELECT * FROM policy_drafts WHERE account_id = ?"
338
+ params: list[object] = [account_id]
339
+ if state is not None:
340
+ sql += " AND state = ?"
341
+ params.append(state)
342
+ rows = self._store.query(sql + " ORDER BY updated_at DESC LIMIT 200", params)
343
+ views = []
344
+ visible = visible_repository_ids(self._store, principal, account_id)
345
+ for row in rows:
346
+ target = self._target_of(row)
347
+ if target.type is PolicyTargetType.REPOSITORY and int(target.id) not in visible:
348
+ continue
349
+ views.append(self._view(row, principal))
350
+ return views
351
+
352
+ def get(self, principal: Principal, draft_id: str) -> PolicyDraftView:
353
+ row = self._authorized_draft(principal, draft_id, Permission.POLICIES_READ)
354
+ return self._view(row, principal)
355
+
356
+ # -- writes ----------------------------------------------------------- #
357
+ def create(
358
+ self,
359
+ principal: Principal,
360
+ account_id: int,
361
+ *,
362
+ target_type: object,
363
+ target_id: object,
364
+ floors: dict[str, Action],
365
+ defaults: dict[str, Action],
366
+ title: object,
367
+ reason: object,
368
+ ) -> PolicyDraftView:
369
+ require(principal, Permission.POLICIES_WRITE, account_id)
370
+ target = parse_target(target_type, target_id)
371
+ label = self._require_target_access(principal, account_id, target)
372
+ clean_title = text(title, "title", limit=MAX_NAME_CHARS) or f"{label} policy change"
373
+ clean_reason = text(reason, "reason", limit=MAX_REASON_CHARS)
374
+ document = canonical_document(floors, defaults)
375
+ if len(document.encode("utf-8")) > MAX_DOCUMENT_BYTES:
376
+ raise InputValidationError("The policy document is too large.")
377
+ current = self._policies.current(account_id, target)
378
+ now = self._now()
379
+ draft_id = new_id()
380
+ with self._store.transaction() as db:
381
+ open_drafts = db.execute(
382
+ "SELECT COUNT(*) AS n FROM policy_drafts WHERE account_id = ? AND state IN "
383
+ "('draft', 'pending_approval', 'approved')",
384
+ (account_id,),
385
+ ).fetchone()["n"]
386
+ if int(open_drafts) >= MAX_OPEN_DRAFTS:
387
+ raise ConflictError(
388
+ f"There are already {MAX_OPEN_DRAFTS} open policy drafts. "
389
+ "Publish or cancel some before creating another."
390
+ )
391
+ db.execute(
392
+ "INSERT INTO policy_drafts (draft_id, account_id, target_type, target_id, "
393
+ "base_version, document, fingerprint, title, reason, state, created_at, "
394
+ "created_by_id, created_by_login, updated_at) "
395
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?)",
396
+ (
397
+ draft_id,
398
+ account_id,
399
+ target.type.value,
400
+ target.id,
401
+ current.version,
402
+ document,
403
+ sha256_hex(document.encode("utf-8")),
404
+ clean_title,
405
+ clean_reason,
406
+ ts(now),
407
+ principal.user_id,
408
+ principal.login,
409
+ ts(now),
410
+ ),
411
+ )
412
+ stored = self._store.insert_audit_event(
413
+ db,
414
+ self._audit.build(
415
+ AuditEventType.POLICY_DRAFT_CREATED,
416
+ actor=Actor.user(principal.user_id, principal.login),
417
+ account_id=account_id,
418
+ draft=draft_id,
419
+ target_type=target.type.value,
420
+ target=label,
421
+ base_version=current.version,
422
+ ),
423
+ )
424
+ self._audit.log_stored(stored)
425
+ return self.get(principal, draft_id)
426
+
427
+ def update(
428
+ self,
429
+ principal: Principal,
430
+ draft_id: str,
431
+ *,
432
+ expected_revision: object,
433
+ floors: dict[str, Action] | None,
434
+ defaults: dict[str, Action] | None,
435
+ title: object,
436
+ reason: object,
437
+ rebase: bool = False,
438
+ ) -> PolicyDraftView:
439
+ """Edit a draft. ``rebase`` bases it on the target's current version."""
440
+ row = self._authorized_draft(principal, draft_id, Permission.POLICIES_WRITE)
441
+ account_id = int(row["account_id"])
442
+ state = str(row["state"])
443
+ if state not in ("draft", "approved", "rejected"):
444
+ raise ConflictError(f"A {state} draft cannot be edited.")
445
+ if not isinstance(expected_revision, int) or isinstance(expected_revision, bool):
446
+ raise InputValidationError(
447
+ "expected_revision must be an integer", field="expected_revision"
448
+ )
449
+ current_floors, current_defaults = parse_document(str(row["document"]))
450
+ document = canonical_document(
451
+ current_floors if floors is None else floors,
452
+ current_defaults if defaults is None else defaults,
453
+ )
454
+ if len(document.encode("utf-8")) > MAX_DOCUMENT_BYTES:
455
+ raise InputValidationError("The policy document is too large.")
456
+ clean_title = text(title, "title", limit=MAX_NAME_CHARS) or row["title"]
457
+ clean_reason = (
458
+ text(reason, "reason", limit=MAX_REASON_CHARS) if reason is not None else row["reason"]
459
+ )
460
+ base_version = int(row["base_version"])
461
+ if rebase:
462
+ base_version = self._policies.current(account_id, self._target_of(row)).version
463
+ now = self._now()
464
+ with self._store.transaction() as db:
465
+ changed = db.execute(
466
+ "UPDATE policy_drafts SET document = ?, fingerprint = ?, title = ?, reason = ?, "
467
+ "base_version = ?, state = 'draft', revision = revision + 1, updated_at = ? "
468
+ "WHERE draft_id = ? AND revision = ? AND state IN ('draft', 'approved', "
469
+ "'rejected')",
470
+ (
471
+ document,
472
+ sha256_hex(document.encode("utf-8")),
473
+ clean_title,
474
+ clean_reason,
475
+ base_version,
476
+ ts(now),
477
+ draft_id,
478
+ expected_revision,
479
+ ),
480
+ ).rowcount
481
+ if changed != 1:
482
+ raise ConflictError("The draft was changed by someone else. Reload before editing.")
483
+ # An edited draft loses its approval: what was approved is no longer what would
484
+ # be published.
485
+ db.execute(
486
+ "UPDATE policy_approvals SET status = 'cancelled', decided_at = ?, "
487
+ "decided_by_login = ?, reason = 'draft edited after approval' "
488
+ "WHERE draft_id = ? AND status IN ('pending', 'approved')",
489
+ (ts(now), principal.login, draft_id),
490
+ )
491
+ stored = self._store.insert_audit_event(
492
+ db,
493
+ self._audit.build(
494
+ AuditEventType.POLICY_DRAFT_UPDATED,
495
+ actor=Actor.user(principal.user_id, principal.login),
496
+ account_id=account_id,
497
+ draft=draft_id,
498
+ revision=int(row["revision"]) + 1,
499
+ base_version=base_version,
500
+ rebased=base_version != int(row["base_version"]),
501
+ ),
502
+ )
503
+ self._audit.log_stored(stored)
504
+ return self.get(principal, draft_id)
505
+
506
+ def submit(self, principal: Principal, draft_id: str) -> PolicyDraftView:
507
+ row = self._authorized_draft(principal, draft_id, Permission.POLICIES_WRITE)
508
+ account_id = int(row["account_id"])
509
+ if str(row["state"]) != "draft":
510
+ raise ConflictError(f"The draft is {row['state']}, not a draft.")
511
+ now = self._now()
512
+ target = self._target_of(row)
513
+ label = self._label(account_id, target)
514
+ approval_id = new_id()
515
+ with self._store.transaction() as db:
516
+ if (
517
+ db.execute(
518
+ "UPDATE policy_drafts SET state = 'pending_approval', submitted_at = ?, "
519
+ "submitted_by_id = ?, submitted_by_login = ?, updated_at = ? "
520
+ "WHERE draft_id = ? AND state = 'draft'",
521
+ (ts(now), principal.user_id, principal.login, ts(now), draft_id),
522
+ ).rowcount
523
+ != 1
524
+ ):
525
+ raise ConflictError("The draft was changed by someone else.")
526
+ db.execute(
527
+ "INSERT INTO policy_approvals (approval_id, draft_id, account_id, fingerprint, "
528
+ "requested_by_id, requested_by_login, requested_at, status) "
529
+ "VALUES (?, ?, ?, ?, ?, ?, ?, 'pending')",
530
+ (
531
+ approval_id,
532
+ draft_id,
533
+ account_id,
534
+ row["fingerprint"],
535
+ principal.user_id,
536
+ principal.login,
537
+ ts(now),
538
+ ),
539
+ )
540
+ stored = self._store.insert_audit_event(
541
+ db,
542
+ self._audit.build(
543
+ AuditEventType.POLICY_APPROVAL_REQUESTED,
544
+ actor=Actor.user(principal.user_id, principal.login),
545
+ account_id=account_id,
546
+ draft=draft_id,
547
+ approval=approval_id,
548
+ target=label,
549
+ title=str(row["title"]),
550
+ ),
551
+ )
552
+ emit(
553
+ db,
554
+ NotificationEvent(
555
+ type=NotificationType.POLICY_APPROVAL_REQUESTED,
556
+ account_id=account_id,
557
+ severity=Severity.HIGH,
558
+ resource_type="draft",
559
+ resource_id=draft_id,
560
+ dedup_key=domain_key(
561
+ NotificationType.POLICY_APPROVAL_REQUESTED, account_id, approval_id
562
+ ),
563
+ title=f"Policy approval requested: {row['title']}",
564
+ body=(
565
+ f"{principal.login} submitted a policy change for {label} for approval. "
566
+ "It is not in effect until an administrator approves and publishes it."
567
+ ),
568
+ metadata={"draft": draft_id, "target": label},
569
+ ),
570
+ now,
571
+ )
572
+ self._audit.log_stored(stored)
573
+ return self.get(principal, draft_id)
574
+
575
+ def decide(
576
+ self, principal: Principal, draft_id: str, *, approve: bool, reason: object
577
+ ) -> PolicyDraftView:
578
+ row = self._authorized_draft(principal, draft_id, Permission.POLICIES_APPROVE)
579
+ account_id = int(row["account_id"])
580
+ if str(row["state"]) != "pending_approval":
581
+ raise ConflictError(f"The draft is {row['state']}, not waiting for approval.")
582
+ settings = load_settings(self._store, account_id).settings
583
+ if settings.require_separate_approver and principal.user_id in {
584
+ row["created_by_id"],
585
+ row["submitted_by_id"],
586
+ }:
587
+ raise PermissionDeniedError(
588
+ "Separation of duties: the author of a policy change cannot approve it."
589
+ )
590
+ note = text(reason, "reason", limit=MAX_REASON_CHARS, required=not approve)
591
+ now = self._now()
592
+ with self._store.transaction() as db:
593
+ approval = db.execute(
594
+ "SELECT approval_id, fingerprint FROM policy_approvals WHERE draft_id = ? "
595
+ "AND status = 'pending'",
596
+ (draft_id,),
597
+ ).fetchone()
598
+ if approval is None:
599
+ raise ConflictError("There is no approval request for this draft.")
600
+ if approval["fingerprint"] != row["fingerprint"]:
601
+ raise ConflictError("The draft changed after it was submitted; submit it again.")
602
+ db.execute(
603
+ "UPDATE policy_approvals SET status = ?, decided_by_id = ?, decided_by_login = ?, "
604
+ "decided_at = ?, reason = ? WHERE approval_id = ? AND status = 'pending'",
605
+ (
606
+ "approved" if approve else "rejected",
607
+ principal.user_id,
608
+ principal.login,
609
+ ts(now),
610
+ note,
611
+ approval["approval_id"],
612
+ ),
613
+ )
614
+ db.execute(
615
+ "UPDATE policy_drafts SET state = ?, updated_at = ? WHERE draft_id = ? "
616
+ "AND state = 'pending_approval'",
617
+ ("approved" if approve else "rejected", ts(now), draft_id),
618
+ )
619
+ stored = self._store.insert_audit_event(
620
+ db,
621
+ self._audit.build(
622
+ AuditEventType.POLICY_APPROVED if approve else AuditEventType.POLICY_REJECTED,
623
+ actor=Actor.user(principal.user_id, principal.login),
624
+ account_id=account_id,
625
+ draft=draft_id,
626
+ approval=str(approval["approval_id"]),
627
+ requested_by=row["submitted_by_login"],
628
+ reason=note,
629
+ ),
630
+ )
631
+ self._audit.log_stored(stored)
632
+ return self.get(principal, draft_id)
633
+
634
+ def cancel(self, principal: Principal, draft_id: str) -> PolicyDraftView:
635
+ row = self._authorized_draft(principal, draft_id, Permission.POLICIES_WRITE)
636
+ account_id = int(row["account_id"])
637
+ if str(row["state"]) in ("published", "cancelled"):
638
+ raise ConflictError(f"The draft is {row['state']}.")
639
+ now = self._now()
640
+ with self._store.transaction() as db:
641
+ db.execute(
642
+ "UPDATE policy_drafts SET state = 'cancelled', updated_at = ? WHERE draft_id = ? "
643
+ "AND state != 'published'",
644
+ (ts(now), draft_id),
645
+ )
646
+ db.execute(
647
+ "UPDATE policy_approvals SET status = 'cancelled', decided_at = ?, "
648
+ "decided_by_login = ?, reason = 'draft cancelled' WHERE draft_id = ? "
649
+ "AND status = 'pending'",
650
+ (ts(now), principal.login, draft_id),
651
+ )
652
+ stored = self._store.insert_audit_event(
653
+ db,
654
+ self._audit.build(
655
+ AuditEventType.POLICY_DRAFT_CANCELLED,
656
+ actor=Actor.user(principal.user_id, principal.login),
657
+ account_id=account_id,
658
+ draft=draft_id,
659
+ ),
660
+ )
661
+ self._audit.log_stored(stored)
662
+ return self.get(principal, draft_id)
663
+
664
+ def publish(
665
+ self,
666
+ principal: Principal,
667
+ draft_id: str,
668
+ *,
669
+ confirm_weakening: bool,
670
+ emergency: bool = False,
671
+ reason: object = None,
672
+ rollout: Callable[[sqlite3.Connection, PublishedPolicy], None] | None = None,
673
+ ) -> PolicyDraftView:
674
+ """Publish a draft as an immutable version. Optionally starts a staged rollout."""
675
+ permission = Permission.POLICIES_EMERGENCY if emergency else Permission.POLICIES_PUBLISH
676
+ row = self._authorized_draft(principal, draft_id, permission)
677
+ account_id = int(row["account_id"])
678
+ state = str(row["state"])
679
+ if state in ("published", "cancelled", "rejected"):
680
+ raise ConflictError(f"A {state} draft cannot be published.")
681
+ settings = load_settings(self._store, account_id).settings
682
+ if settings.require_policy_approval and state != "approved" and not emergency:
683
+ raise ApprovalRequiredError(
684
+ "This organization requires policy changes to be approved before they are "
685
+ "published. Submit the draft for approval, or use an emergency publication."
686
+ )
687
+ publish_reason = text(reason, "reason", limit=MAX_REASON_CHARS, required=emergency)
688
+ target = self._target_of(row)
689
+ floors, defaults = parse_document(str(row["document"]))
690
+ now = self._now()
691
+
692
+ def mark_published(db: sqlite3.Connection, published: PublishedPolicy) -> None:
693
+ db.execute(
694
+ "UPDATE policy_drafts SET state = 'published', published_version = ?, "
695
+ "published_at = ?, published_by_login = ?, emergency = ?, updated_at = ? "
696
+ "WHERE draft_id = ?",
697
+ (
698
+ published.version,
699
+ ts(now),
700
+ principal.login,
701
+ 1 if emergency else 0,
702
+ ts(now),
703
+ draft_id,
704
+ ),
705
+ )
706
+ if rollout is not None:
707
+ rollout(db, published)
708
+ db.execute(
709
+ "UPDATE policy_drafts SET rollout_id = (SELECT rollout_id FROM "
710
+ "policy_rollouts WHERE account_id = ? AND target_type = ? AND target_id = ? "
711
+ "AND to_version = ?) WHERE draft_id = ?",
712
+ (account_id, target.type.value, target.id, published.version, draft_id),
713
+ )
714
+
715
+ self._policies.update(
716
+ account_id=account_id,
717
+ actor=Actor.user(principal.user_id, principal.login),
718
+ authenticated_at=principal.authenticated_at,
719
+ expected_version=int(row["base_version"]),
720
+ floors=floors,
721
+ defaults=defaults,
722
+ reason=publish_reason or row["reason"],
723
+ confirm_weakening=confirm_weakening,
724
+ target=target,
725
+ draft_id=draft_id,
726
+ emergency=emergency,
727
+ hooks=[mark_published],
728
+ )
729
+ return self.get(principal, draft_id)
730
+
731
+ def summarize(self, account_id: int, target: PolicyTarget, document: str) -> str:
732
+ current = self._policies.current(account_id, target)
733
+ floors, defaults = parse_document(document)
734
+ return change_summary(policy_changes(current.floors, floors, current.defaults, defaults))