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,371 @@
1
+ """Organization rules: declarative identity data added to the trusted detection rules.
2
+
3
+ Detection logic stays in the trusted detection engine. What an organization can
4
+ add is **data** of the kinds the bundled rule files already contain:
5
+
6
+ * ``ai_identities`` - additional AI agents (e.g. an internal coding agent),
7
+ detected by the same ``coauthor``, ``identity`` and ``trailer`` detectors and
8
+ reported under the same rule IDs (``ai_coauthor``, ``ai_identity``, ...);
9
+ * ``bot_identities`` - additional automation accounts (``bot_identity``).
10
+
11
+ Each entry has an ``id``, a display name and exact identifiers - full names,
12
+ distinctive name prefixes, e-mail addresses, GitHub logins. Matching uses the
13
+ bundled matcher: exact comparison after normalisation. There are **no regular
14
+ expressions, wildcards, expressions, scripts or imports**, so an organization
15
+ rule cannot execute code and cannot cause catastrophic backtracking (ReDoS).
16
+ The same validation as the bundled files applies (plausible e-mails, no empty
17
+ aliases) plus limits on entries and values, and an alias already claimed by a
18
+ bundled agent is rejected rather than silently overriding it.
19
+
20
+ Trust levels
21
+ ============
22
+
23
+ ================== ============================ ===================================
24
+ Level Source Can
25
+ ================== ============================ ===================================
26
+ ``built_in`` rule files in the package define rules and detectors
27
+ ``organization`` this module, versioned add identity data to existing rules
28
+ ``repository`` ``.commitguard.yaml`` configure policies only - no rules
29
+ ================== ============================ ===================================
30
+
31
+ Versions are immutable (database triggers). A scan records the rules version
32
+ it used: ``<bundled rules fingerprint>`` or ``<fingerprint>+org-v<N>``. Changing
33
+ organization rules needs ``rules:manage``, is audited, and invalidates the
34
+ effective policy of every repository of the organization, so scheduled scans
35
+ re-evaluate branches under the new rules.
36
+ """
37
+
38
+ import json
39
+ import sqlite3
40
+ from collections.abc import Callable
41
+ from datetime import UTC, datetime
42
+ from functools import lru_cache
43
+ from typing import Any
44
+
45
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
46
+
47
+ from commitguard.audit.models import Actor, AuditEventType
48
+ from commitguard.controlplane.access import Permission, Principal
49
+ from commitguard.controlplane.errors import ConflictError, InputValidationError
50
+ from commitguard.github.storage import SqliteStateStore
51
+ from commitguard.governance.cache import invalidate_repositories
52
+ from commitguard.governance.common import MAX_REASON_CHARS, dt, req_dt, require, text, ts
53
+ from commitguard.rules.loader import builtin_rules_fingerprint, load_builtin_rules
54
+ from commitguard.rules.matcher import CompiledRules
55
+ from commitguard.rules.models import AIIdentityRules, BotIdentityRules, IdentityRule, RuleSet
56
+ from commitguard.security.hashing import sha256_hex
57
+ from commitguard.services.audit import AuditService
58
+
59
+ MAX_ENTRIES = 100
60
+ MAX_VALUES = 20
61
+ MAX_VALUE_CHARS = 128
62
+ MAX_DOCUMENT_BYTES = 65_536
63
+ ID_PREFIX = "org_"
64
+ TRUST_LEVELS = ("built_in", "organization", "repository")
65
+
66
+
67
+ class OrganizationIdentity(BaseModel):
68
+ model_config = ConfigDict(frozen=True, extra="forbid")
69
+
70
+ id: str = Field(min_length=1, max_length=48, pattern=r"^[a-z][a-z0-9_]{0,47}$")
71
+ display_name: str = Field(min_length=1, max_length=MAX_VALUE_CHARS)
72
+ names: tuple[str, ...] = ()
73
+ name_prefixes: tuple[str, ...] = ()
74
+ emails: tuple[str, ...] = ()
75
+ github_logins: tuple[str, ...] = ()
76
+
77
+ @field_validator("names", "name_prefixes", "emails", "github_logins")
78
+ @classmethod
79
+ def _bounded(cls, value: tuple[str, ...]) -> tuple[str, ...]:
80
+ if len(value) > MAX_VALUES:
81
+ raise ValueError(f"at most {MAX_VALUES} values")
82
+ for item in value:
83
+ if not isinstance(item, str) or not item.strip() or len(item) > MAX_VALUE_CHARS:
84
+ raise ValueError(f"values must be 1-{MAX_VALUE_CHARS} characters")
85
+ if any(ord(ch) < 32 for ch in item):
86
+ raise ValueError("values must not contain control characters")
87
+ return tuple(dict.fromkeys(item.strip() for item in value))
88
+
89
+
90
+ class OrganizationRuleDocument(BaseModel):
91
+ model_config = ConfigDict(frozen=True, extra="forbid")
92
+
93
+ ai_identities: tuple[OrganizationIdentity, ...] = ()
94
+ bot_identities: tuple[OrganizationIdentity, ...] = ()
95
+
96
+ @field_validator("ai_identities", "bot_identities")
97
+ @classmethod
98
+ def _limit(cls, value: tuple[OrganizationIdentity, ...]) -> tuple[OrganizationIdentity, ...]:
99
+ if len(value) > MAX_ENTRIES:
100
+ raise ValueError(f"at most {MAX_ENTRIES} entries")
101
+ return value
102
+
103
+ def canonical(self) -> str:
104
+ return json.dumps(self.model_dump(mode="json"), sort_keys=True, separators=(",", ":"))
105
+
106
+
107
+ class OrganizationRulesView(BaseModel):
108
+ model_config = ConfigDict(frozen=True, extra="forbid")
109
+
110
+ organization_id: int
111
+ version: int
112
+ fingerprint: str | None
113
+ document: OrganizationRuleDocument
114
+ rules_version: str # as recorded with scans
115
+ created_at: datetime | None
116
+ created_by: str | None
117
+ reason: str | None
118
+ trust_levels: tuple[dict[str, str], ...]
119
+ can_manage: bool
120
+
121
+
122
+ def _identity_rule(entry: OrganizationIdentity) -> IdentityRule:
123
+ return IdentityRule(
124
+ id=f"{ID_PREFIX}{entry.id}",
125
+ display_name=entry.display_name,
126
+ vendor="organization",
127
+ names=entry.names,
128
+ name_prefixes=entry.name_prefixes,
129
+ emails=entry.emails,
130
+ github_logins=entry.github_logins,
131
+ verified=False,
132
+ reference="organization rules",
133
+ )
134
+
135
+
136
+ def compile_rules(document: OrganizationRuleDocument) -> CompiledRules:
137
+ """The bundled rules plus the organization's identity data. Raises ``ValueError``."""
138
+ builtin = load_builtin_rules().rules
139
+ rules = RuleSet(
140
+ ai_identities=AIIdentityRules(
141
+ schema_version=1,
142
+ agents=(
143
+ *builtin.ai_identities.agents,
144
+ *(_identity_rule(e) for e in document.ai_identities),
145
+ ),
146
+ ),
147
+ ai_domains=builtin.ai_domains,
148
+ bots=BotIdentityRules(
149
+ schema_version=1,
150
+ bots=(*builtin.bots.bots, *(_identity_rule(e) for e in document.bot_identities)),
151
+ ),
152
+ patterns=builtin.patterns,
153
+ )
154
+ return CompiledRules(rules)
155
+
156
+
157
+ @lru_cache(maxsize=64)
158
+ def _compiled_for(fingerprint: str, canonical: str) -> CompiledRules:
159
+ del fingerprint # part of the cache key only
160
+ return compile_rules(OrganizationRuleDocument.model_validate_json(canonical))
161
+
162
+
163
+ def rules_version_label(version: int) -> str:
164
+ base = builtin_rules_fingerprint()
165
+ return base if version == 0 else f"{base[:16]}+org-v{version}"
166
+
167
+
168
+ def parse_document(raw: object) -> OrganizationRuleDocument:
169
+ if not isinstance(raw, dict):
170
+ raise InputValidationError("rules must be an object", field="rules")
171
+ try:
172
+ document = OrganizationRuleDocument.model_validate(raw)
173
+ except ValidationError as exc:
174
+ error = exc.errors()[0]
175
+ location = ".".join(str(part) for part in error.get("loc", ()))
176
+ raise InputValidationError(
177
+ f"Invalid rule {location}: {error.get('msg', 'invalid value')}",
178
+ field=f"rules.{location}",
179
+ ) from None
180
+ for kind, entries in (
181
+ ("ai_identities", document.ai_identities),
182
+ ("bot_identities", document.bot_identities),
183
+ ):
184
+ ids = [e.id for e in entries]
185
+ if len(ids) != len(set(ids)):
186
+ raise InputValidationError(f"duplicate id in {kind}", field=f"rules.{kind}")
187
+ for entry in entries:
188
+ if not (entry.names or entry.name_prefixes or entry.emails or entry.github_logins):
189
+ raise InputValidationError(
190
+ f"{kind} entry {entry.id!r} needs names, prefixes, e-mails or logins",
191
+ field=f"rules.{kind}",
192
+ )
193
+ if len(document.canonical().encode("utf-8")) > MAX_DOCUMENT_BYTES:
194
+ raise InputValidationError("The organization rules are too large.", field="rules")
195
+ try:
196
+ compile_rules(document)
197
+ except ValidationError as exc:
198
+ message = str(exc.errors()[0].get("msg", "invalid rule"))[:300]
199
+ raise InputValidationError(
200
+ f"The rules conflict with the bundled rules: {message}", field="rules"
201
+ ) from None
202
+ except ValueError as exc:
203
+ message = str(exc)[:300] or "conflicts with bundled rules"
204
+ raise InputValidationError(
205
+ f"The rules conflict with the bundled rules: {message}", field="rules"
206
+ ) from None
207
+ return document
208
+
209
+
210
+ class OrganizationRuleService:
211
+ def __init__(
212
+ self,
213
+ store: SqliteStateStore,
214
+ audit: AuditService,
215
+ *,
216
+ now: Callable[[], datetime] = lambda: datetime.now(UTC),
217
+ ) -> None:
218
+ self._store = store
219
+ self._audit = audit
220
+ self._now = now
221
+
222
+ def _latest(self, db: sqlite3.Connection | SqliteStateStore, account_id: int) -> Any:
223
+ sql = (
224
+ "SELECT * FROM organization_rule_versions WHERE account_id = ? "
225
+ "ORDER BY version DESC LIMIT 1"
226
+ )
227
+ if isinstance(db, sqlite3.Connection):
228
+ return db.execute(sql, (account_id,)).fetchone()
229
+ rows = db.query(sql, (account_id,))
230
+ return rows[0] if rows else None
231
+
232
+ def current(
233
+ self, account_id: int, db: sqlite3.Connection | None = None
234
+ ) -> tuple[int, OrganizationRuleDocument, str | None]:
235
+ row = self._latest(db if db is not None else self._store, account_id)
236
+ if row is None:
237
+ return 0, OrganizationRuleDocument(), None
238
+ stored = str(row["document"])
239
+ if sha256_hex(stored.encode("utf-8")) != row["fingerprint"]:
240
+ # A tampered document is not used: fail closed to the bundled rules only,
241
+ # which never weakens detection (organization rules only add identities).
242
+ return int(row["version"]), OrganizationRuleDocument(), row["fingerprint"]
243
+ return (
244
+ int(row["version"]),
245
+ OrganizationRuleDocument.model_validate_json(stored),
246
+ str(row["fingerprint"]),
247
+ )
248
+
249
+ def compiled(self, account_id: int) -> tuple[CompiledRules | None, str]:
250
+ """Rules for scans of the account and the version label to record."""
251
+ version, document, fingerprint = self.current(account_id)
252
+ if version == 0 or not (document.ai_identities or document.bot_identities):
253
+ return None, rules_version_label(0)
254
+ return _compiled_for(fingerprint or "", document.canonical()), rules_version_label(version)
255
+
256
+ def view(self, principal: Principal, account_id: int) -> OrganizationRulesView:
257
+ require(principal, Permission.RULES_READ, account_id)
258
+ row = self._latest(self._store, account_id)
259
+ version, document, fingerprint = self.current(account_id)
260
+ return OrganizationRulesView(
261
+ organization_id=account_id,
262
+ version=version,
263
+ fingerprint=fingerprint,
264
+ document=document,
265
+ rules_version=rules_version_label(version),
266
+ created_at=dt(row["created_at"]) if row else None,
267
+ created_by=row["created_by_login"] if row else None,
268
+ reason=row["reason"] if row else None,
269
+ trust_levels=(
270
+ {
271
+ "level": "built_in",
272
+ "source": "rule files bundled with CommitGuard",
273
+ "can": "define rules and detectors",
274
+ },
275
+ {
276
+ "level": "organization",
277
+ "source": "organization rules (this page), versioned",
278
+ "can": "add AI agent and bot identities to existing rules",
279
+ },
280
+ {
281
+ "level": "repository",
282
+ "source": ".commitguard.yaml",
283
+ "can": "configure policies; cannot add rules or weaken mandatory policy",
284
+ },
285
+ ),
286
+ can_manage=principal.can(Permission.RULES_MANAGE, account_id),
287
+ )
288
+
289
+ def update(
290
+ self,
291
+ principal: Principal,
292
+ account_id: int,
293
+ *,
294
+ expected_version: object,
295
+ rules: object,
296
+ reason: object,
297
+ ) -> OrganizationRulesView:
298
+ require(principal, Permission.RULES_MANAGE, account_id)
299
+ if (
300
+ not isinstance(expected_version, int)
301
+ or isinstance(expected_version, bool)
302
+ or expected_version < 0
303
+ ):
304
+ raise InputValidationError(
305
+ "expected_version must be a non-negative integer", field="expected_version"
306
+ )
307
+ document = parse_document(rules)
308
+ reason_text = text(reason, "reason", limit=MAX_REASON_CHARS, required=True)
309
+ canonical = document.canonical()
310
+ now = self._now()
311
+ with self._store.transaction() as db:
312
+ latest, current, _ = self.current(account_id, db)
313
+ if latest != expected_version:
314
+ raise ConflictError(
315
+ f"The organization rules were changed by someone else (now version {latest})."
316
+ )
317
+ if current.canonical() == canonical:
318
+ raise InputValidationError("The rules are identical to the current version.")
319
+ removed = sorted(
320
+ {e.id for e in (*current.ai_identities, *current.bot_identities)}
321
+ - {e.id for e in (*document.ai_identities, *document.bot_identities)}
322
+ )
323
+ db.execute(
324
+ "INSERT INTO organization_rule_versions (account_id, version, document, "
325
+ "fingerprint, created_at, created_by_id, created_by_login, reason) "
326
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
327
+ (
328
+ account_id,
329
+ latest + 1,
330
+ canonical,
331
+ sha256_hex(canonical.encode("utf-8")),
332
+ ts(now),
333
+ principal.user_id,
334
+ principal.login,
335
+ reason_text,
336
+ ),
337
+ )
338
+ invalidate_repositories(db, account_id, None, now)
339
+ stored = self._store.insert_audit_event(
340
+ db,
341
+ self._audit.build(
342
+ AuditEventType.ORGANIZATION_RULES_CHANGED,
343
+ actor=Actor.user(principal.user_id, principal.login),
344
+ account_id=account_id,
345
+ old_version=latest,
346
+ new_version=latest + 1,
347
+ ai_identities=len(document.ai_identities),
348
+ bot_identities=len(document.bot_identities),
349
+ removed=",".join(removed) or None,
350
+ reason=reason_text,
351
+ ),
352
+ )
353
+ self._audit.log_stored(stored)
354
+ return self.view(principal, account_id)
355
+
356
+ def history(self, principal: Principal, account_id: int) -> list[dict[str, object]]:
357
+ require(principal, Permission.RULES_READ, account_id)
358
+ return [
359
+ {
360
+ "version": int(r["version"]),
361
+ "fingerprint": r["fingerprint"],
362
+ "created_at": req_dt(r["created_at"]).isoformat(),
363
+ "created_by": r["created_by_login"],
364
+ "reason": r["reason"],
365
+ }
366
+ for r in self._store.query(
367
+ "SELECT version, fingerprint, created_at, created_by_login, reason FROM "
368
+ "organization_rule_versions WHERE account_id = ? ORDER BY version DESC LIMIT 50",
369
+ (account_id,),
370
+ )
371
+ ]