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,632 @@
1
+ """GovernanceResolver: which governance inputs apply to a repository, cached and propagated.
2
+
3
+ The resolver *collects* what the organization layer decided for one repository
4
+ and hands it to the pure :func:`commitguard.policies.governance.resolve_policy`
5
+ (which builds the policy set) - the policy engine then evaluates findings with
6
+ that set. Nothing here evaluates a finding.
7
+
8
+ Inputs, in the order they are layered::
9
+
10
+ service policy operator file (mandatory)
11
+ security baseline organization settings (mandatory)
12
+ organization policy the version that applies to this repository:
13
+ - a staged rollout in progress: the new version for
14
+ enrolled repositories, the previous one for the rest
15
+ - otherwise the newest published version
16
+ repository group policies every non-archived group the repository belongs to
17
+ (rollout-aware per group)
18
+ repository policy the newest published version for this repository
19
+ approved exceptions active, unexpired, scoped to the organization, one of
20
+ its groups, or the repository
21
+ mode monitor / enforce
22
+
23
+ Cache and propagation
24
+ =====================
25
+
26
+ Resolution runs inside one database transaction (a consistent snapshot, and
27
+ SQLite serialises writers), and its result is stored in
28
+ ``repository_effective_policies`` as ``up_to_date``. Every change that affects a
29
+ repository marks its row ``stale`` in the change's own transaction
30
+ (:mod:`commitguard.governance.cache`), so a scan uses a cached row only when it
31
+ is current - otherwise it resolves again. Rows also carry ``valid_until``: the
32
+ earliest expiry of an exception they include, after which they are resolved
33
+ again even before the expiry worker runs.
34
+
35
+ The propagation job (:meth:`GovernanceResolver.propagate`) resolves stale rows
36
+ and rows of repositories never resolved, in bounded batches. A repository whose
37
+ resolution fails is ``error``: administrators are notified and the dashboard
38
+ shows it, and a scan of that repository resolves directly - if that also fails,
39
+ the scan fails closed. Propagation status is reported as counts; a policy change
40
+ is never shown as propagated while any affected repository is stale, syncing or
41
+ in error.
42
+ """
43
+
44
+ import json
45
+ import sqlite3
46
+ from collections.abc import Callable
47
+ from datetime import UTC, datetime
48
+
49
+ from pydantic import BaseModel, ConfigDict
50
+
51
+ from commitguard.audit.models import SYSTEM_ACTOR, AuditEventType
52
+ from commitguard.controlplane.access import Permission, Principal
53
+ from commitguard.controlplane.errors import NotFoundError
54
+ from commitguard.controlplane.policies import (
55
+ ORGANIZATION_TARGET,
56
+ OrganizationPolicyService,
57
+ PolicyTarget,
58
+ PublishedPolicy,
59
+ parse_document,
60
+ )
61
+ from commitguard.core.decision import Action
62
+ from commitguard.core.result import Severity
63
+ from commitguard.github.storage import SqliteStateStore
64
+ from commitguard.governance.cache import invalidate_repositories, invalidate_scope
65
+ from commitguard.governance.common import (
66
+ account_repositories,
67
+ account_repository,
68
+ dt,
69
+ require,
70
+ ts,
71
+ visible_repository_ids,
72
+ )
73
+ from commitguard.governance.exceptions import active_grants
74
+ from commitguard.governance.inventory import governance_state
75
+ from commitguard.governance.settings import OrganizationSettings, load_settings
76
+ from commitguard.notifications.deduplication import domain_key
77
+ from commitguard.notifications.models import NotificationEvent, NotificationType
78
+ from commitguard.notifications.outbox import emit
79
+ from commitguard.observability.logging import get_logger
80
+ from commitguard.policies.defaults import DEFAULT_POLICIES
81
+ from commitguard.policies.governance import (
82
+ EffectivePolicy,
83
+ Enforcement,
84
+ GovernanceInputs,
85
+ PolicyLayer,
86
+ PolicyLevel,
87
+ RepositoryMode,
88
+ RuleRequirement,
89
+ resolve_policy,
90
+ )
91
+ from commitguard.security.hashing import sha256_hex
92
+ from commitguard.services.audit import AuditService
93
+
94
+ log = get_logger(__name__)
95
+
96
+ PROPAGATION_BATCH = 200
97
+ MAX_PROPAGATION_ATTEMPTS = 5
98
+ PROPAGATION_STATES = ("up_to_date", "stale", "syncing", "error", "pending")
99
+
100
+
101
+ class GovernanceVersions(BaseModel):
102
+ """Which version of each governance source applied (recorded with scans)."""
103
+
104
+ model_config = ConfigDict(frozen=True, extra="forbid")
105
+
106
+ organization_policy: int | None = None
107
+ settings: int | None = None
108
+ groups: dict[str, int] = {}
109
+ repository_policy: int | None = None
110
+ rollouts: dict[str, int] = {} # rollout ID -> version applied through it
111
+ exceptions: tuple[str, ...] = ()
112
+ organization_rules: int | None = None # organization rules version, when any
113
+
114
+
115
+ class ResolvedGovernance(BaseModel):
116
+ model_config = ConfigDict(frozen=True, extra="forbid")
117
+
118
+ account_id: int
119
+ repository_id: int
120
+ inputs: GovernanceInputs
121
+ versions: GovernanceVersions
122
+ valid_until: datetime | None
123
+ resolved_at: datetime
124
+
125
+ @property
126
+ def fingerprint(self) -> str:
127
+ """Everything that can change a decision: policy inputs and organization rules."""
128
+ rules = self.versions.organization_rules or 0
129
+ return sha256_hex(f"{self.inputs.fingerprint}:rules-v{rules}".encode())
130
+
131
+ def document(self) -> str:
132
+ return self.model_dump_json()
133
+
134
+
135
+ class PropagationStatus(BaseModel):
136
+ model_config = ConfigDict(frozen=True, extra="forbid")
137
+
138
+ organization_id: int
139
+ repositories: int
140
+ up_to_date: int
141
+ stale: int
142
+ syncing: int
143
+ error: int
144
+ pending: int # never resolved yet
145
+ complete: bool # every repository up to date
146
+ failing: tuple[dict[str, object], ...] # repositories in error (visible ones)
147
+ checked_at: datetime
148
+
149
+
150
+ class EffectivePolicyView(BaseModel):
151
+ """A repository's effective policy with provenance, as the dashboard shows it."""
152
+
153
+ model_config = ConfigDict(frozen=True, extra="forbid")
154
+
155
+ organization_id: int
156
+ repository_id: int
157
+ full_name: str
158
+ mode: RepositoryMode
159
+ effective: EffectivePolicy
160
+ versions: GovernanceVersions
161
+ propagation: str
162
+ resolved_at: datetime
163
+ #: From the latest completed scan: conflicts involving the repository's own
164
+ #: .commitguard.yaml, which is only known at scan time.
165
+ last_scan_id: str | None
166
+ last_scan_completed_at: datetime | None
167
+ last_scan_effective: EffectivePolicy | None
168
+ last_scan_used_current_policy: bool | None
169
+
170
+
171
+ def _service_layer(policies: OrganizationPolicyService) -> PolicyLayer | None:
172
+ service = policies.service_policy
173
+ if service is None or not service.config.policies:
174
+ return None
175
+ rules = {
176
+ rule: RuleRequirement(action=override.action or DEFAULT_POLICIES[rule].action)
177
+ for rule, override in service.config.policies.items()
178
+ if (override.action or DEFAULT_POLICIES[rule].action) is not Action.ALLOW
179
+ }
180
+ return PolicyLayer(
181
+ level=PolicyLevel.SERVICE,
182
+ label=f"mandatory policy ({service.description})"[:200],
183
+ rules=rules,
184
+ )
185
+
186
+
187
+ def _row_version(row: sqlite3.Row | None) -> int:
188
+ return int(row["latest"] or 0) if row is not None else 0
189
+
190
+
191
+ class GovernanceResolver:
192
+ def __init__(
193
+ self,
194
+ store: SqliteStateStore,
195
+ policies: OrganizationPolicyService,
196
+ audit: AuditService,
197
+ *,
198
+ now: Callable[[], datetime] = lambda: datetime.now(UTC),
199
+ ) -> None:
200
+ self._store = store
201
+ self._policies = policies
202
+ self._audit = audit
203
+ self._now = now
204
+ policies.add_publish_hook(self.on_policy_published)
205
+
206
+ # -- invalidation hooks ----------------------------------------------- #
207
+ def on_policy_published(self, db: sqlite3.Connection, published: PublishedPolicy) -> None:
208
+ invalidate_scope(
209
+ db,
210
+ published.account_id,
211
+ published.target.type.value,
212
+ published.target.id,
213
+ published.now,
214
+ )
215
+
216
+ def on_settings_saved(
217
+ self, db: sqlite3.Connection, account_id: int, _settings: OrganizationSettings
218
+ ) -> None:
219
+ invalidate_repositories(db, account_id, None, self._now())
220
+
221
+ # -- resolution ------------------------------------------------------- #
222
+ def _version_for(
223
+ self,
224
+ db: sqlite3.Connection,
225
+ account_id: int,
226
+ target: PolicyTarget,
227
+ repository_id: int,
228
+ rollouts: dict[str, int],
229
+ ) -> int:
230
+ if target.scoped:
231
+ latest = _row_version(
232
+ db.execute(
233
+ "SELECT MAX(version) AS latest FROM scoped_policy_versions WHERE "
234
+ "account_id = ? AND target_type = ? AND target_id = ?",
235
+ (account_id, target.type.value, target.id),
236
+ ).fetchone()
237
+ )
238
+ else:
239
+ latest = _row_version(
240
+ db.execute(
241
+ "SELECT MAX(version) AS latest FROM organization_policy_versions "
242
+ "WHERE account_id = ?",
243
+ (account_id,),
244
+ ).fetchone()
245
+ )
246
+ rollout = db.execute(
247
+ "SELECT rollout_id, from_version, to_version FROM policy_rollouts WHERE "
248
+ "account_id = ? AND target_type = ? AND target_id = ? AND state IN "
249
+ "('pilot', 'rollout', 'paused')",
250
+ (account_id, target.type.value, target.id),
251
+ ).fetchone()
252
+ if rollout is None or int(rollout["to_version"]) != latest:
253
+ return latest
254
+ enrolled = db.execute(
255
+ "SELECT 1 FROM policy_rollout_repositories WHERE rollout_id = ? AND repository_id = ?",
256
+ (rollout["rollout_id"], repository_id),
257
+ ).fetchone()
258
+ version = latest if enrolled else int(rollout["from_version"])
259
+ rollouts[str(rollout["rollout_id"])] = version
260
+ return version
261
+
262
+ def _layer(
263
+ self,
264
+ account_id: int,
265
+ target: PolicyTarget,
266
+ version: int,
267
+ level: PolicyLevel,
268
+ label: str,
269
+ ) -> PolicyLayer | None:
270
+ if version <= 0:
271
+ return None
272
+ stored = self._policies.version(account_id, version, target)
273
+ if stored is None:
274
+ raise RuntimeError(f"policy version {version} is missing")
275
+ floors, defaults = parse_document(stored.document or "{}")
276
+ rules = {
277
+ rule: RuleRequirement(action=action, enforcement=Enforcement.DEFAULT)
278
+ for rule, action in defaults.items()
279
+ }
280
+ rules.update({rule: RuleRequirement(action=action) for rule, action in floors.items()})
281
+ if not rules:
282
+ return None
283
+ return PolicyLayer(
284
+ level=level,
285
+ source_id=target.id,
286
+ label=f"{label} v{version}"[:200],
287
+ version=version,
288
+ rules=rules,
289
+ )
290
+
291
+ def resolve_in(
292
+ self, db: sqlite3.Connection, account_id: int, repository_id: int
293
+ ) -> ResolvedGovernance:
294
+ """Resolve from the database (no cache), inside the caller's transaction."""
295
+ now = self._now()
296
+ layers: list[PolicyLayer] = []
297
+ service = _service_layer(self._policies)
298
+ if service is not None:
299
+ layers.append(service)
300
+ stored_settings = load_settings(db, account_id)
301
+ baseline = stored_settings.settings.security_baseline
302
+ if baseline:
303
+ layers.append(
304
+ PolicyLayer(
305
+ level=PolicyLevel.ORGANIZATION,
306
+ source_id="baseline",
307
+ label=f"security baseline (settings v{stored_settings.version})",
308
+ version=stored_settings.version or None,
309
+ rules={rule: RuleRequirement(action=a) for rule, a in baseline.items()},
310
+ )
311
+ )
312
+ rollouts: dict[str, int] = {}
313
+ organization_version = self._version_for(
314
+ db, account_id, ORGANIZATION_TARGET, repository_id, rollouts
315
+ )
316
+ organization = self._layer(
317
+ account_id,
318
+ ORGANIZATION_TARGET,
319
+ organization_version,
320
+ PolicyLevel.ORGANIZATION,
321
+ "organization policy",
322
+ )
323
+ if organization is not None:
324
+ layers.append(organization)
325
+ groups: dict[str, int] = {}
326
+ group_rows = db.execute(
327
+ "SELECT g.group_id, g.name FROM repository_group_members m JOIN repository_groups g "
328
+ "ON g.group_id = m.group_id WHERE m.account_id = ? AND m.repository_id = ? "
329
+ "AND g.archived_at IS NULL ORDER BY g.name_key LIMIT 50",
330
+ (account_id, repository_id),
331
+ ).fetchall()
332
+ for group in group_rows:
333
+ target = PolicyTarget.group(str(group["group_id"]))
334
+ version = self._version_for(db, account_id, target, repository_id, rollouts)
335
+ groups[str(group["group_id"])] = version
336
+ layer = self._layer(
337
+ account_id, target, version, PolicyLevel.GROUP, f"group {group['name']} policy"
338
+ )
339
+ if layer is not None:
340
+ layers.append(layer)
341
+ repository_target = PolicyTarget.repository(repository_id)
342
+ repository_version = self._version_for(
343
+ db, account_id, repository_target, repository_id, rollouts
344
+ )
345
+ repository_layer = self._layer(
346
+ account_id,
347
+ repository_target,
348
+ repository_version,
349
+ PolicyLevel.REPOSITORY_POLICY,
350
+ "repository policy",
351
+ )
352
+ if repository_layer is not None:
353
+ layers.append(repository_layer)
354
+ grants = active_grants(
355
+ db, account_id, repository_id, [str(g["group_id"]) for g in group_rows], now
356
+ )
357
+ mode = governance_state(db, account_id, repository_id).mode
358
+ rules_row = db.execute(
359
+ "SELECT MAX(version) AS latest FROM organization_rule_versions WHERE account_id = ?",
360
+ (account_id,),
361
+ ).fetchone()
362
+ expiries = [g.expires_at for g in grants if g.expires_at is not None]
363
+ return ResolvedGovernance(
364
+ account_id=account_id,
365
+ repository_id=repository_id,
366
+ inputs=GovernanceInputs(layers=tuple(layers), exceptions=tuple(grants), mode=mode),
367
+ versions=GovernanceVersions(
368
+ organization_policy=organization_version or None,
369
+ settings=stored_settings.version or None,
370
+ groups=groups,
371
+ repository_policy=repository_version or None,
372
+ rollouts=rollouts,
373
+ exceptions=tuple(g.exception_id for g in grants),
374
+ organization_rules=_row_version(rules_row) or None,
375
+ ),
376
+ valid_until=min(expiries) if expiries else None,
377
+ resolved_at=now,
378
+ )
379
+
380
+ def _store_resolution(self, db: sqlite3.Connection, resolved: ResolvedGovernance) -> None:
381
+ db.execute(
382
+ "INSERT INTO repository_effective_policies (account_id, repository_id, state, "
383
+ "fingerprint, document, computed_at, valid_until, attempts, error) "
384
+ "VALUES (?, ?, 'up_to_date', ?, ?, ?, ?, 0, NULL) "
385
+ "ON CONFLICT (account_id, repository_id) DO UPDATE SET state = 'up_to_date', "
386
+ "fingerprint = excluded.fingerprint, document = excluded.document, "
387
+ "computed_at = excluded.computed_at, valid_until = excluded.valid_until, "
388
+ "attempts = 0, error = NULL",
389
+ (
390
+ resolved.account_id,
391
+ resolved.repository_id,
392
+ resolved.fingerprint,
393
+ resolved.document(),
394
+ ts(resolved.resolved_at),
395
+ ts(resolved.valid_until) if resolved.valid_until else None,
396
+ ),
397
+ )
398
+
399
+ def for_repository(self, account_id: int, repository_id: int) -> ResolvedGovernance:
400
+ """Current governance for a repository: the cached row if current, else resolved."""
401
+ now = self._now()
402
+ rows = self._store.query(
403
+ "SELECT state, document, valid_until FROM repository_effective_policies "
404
+ "WHERE account_id = ? AND repository_id = ?",
405
+ (account_id, repository_id),
406
+ )
407
+ if rows and rows[0]["state"] == "up_to_date" and rows[0]["document"]:
408
+ valid_until = dt(rows[0]["valid_until"])
409
+ if valid_until is None or valid_until > now:
410
+ try:
411
+ return ResolvedGovernance.model_validate_json(str(rows[0]["document"]))
412
+ except ValueError:
413
+ log.warning("effective_policy_cache_unreadable", repository_id=repository_id)
414
+ with self._store.transaction() as db:
415
+ resolved = self.resolve_in(db, account_id, repository_id)
416
+ self._store_resolution(db, resolved)
417
+ return resolved
418
+
419
+ def for_scan(self, installation_id: int, repository_id: int) -> ResolvedGovernance | None:
420
+ """Governance for a scan job; None when the installation has no organization record."""
421
+ rows = self._store.query(
422
+ "SELECT account_id FROM installations WHERE installation_id = ?", (installation_id,)
423
+ )
424
+ if not rows:
425
+ return None
426
+ return self.for_repository(int(rows[0]["account_id"]), repository_id)
427
+
428
+ # -- propagation ------------------------------------------------------ #
429
+ def propagate(self, limit: int = PROPAGATION_BATCH) -> dict[str, int]:
430
+ """Resolve stale, failed and never-resolved repositories (bounded)."""
431
+ pending = self._store.query(
432
+ "SELECT account_id, repository_id FROM repository_effective_policies "
433
+ "WHERE state IN ('stale', 'error', 'syncing') AND attempts < ? "
434
+ "ORDER BY invalidated_at LIMIT ?",
435
+ (MAX_PROPAGATION_ATTEMPTS, int(limit)),
436
+ )
437
+ work = [(int(r["account_id"]), int(r["repository_id"])) for r in pending]
438
+ if len(work) < limit:
439
+ missing = self._store.query(
440
+ "SELECT DISTINCT i.account_id, k.repository_id FROM known_repositories k "
441
+ "JOIN installations i ON i.installation_id = k.installation_id "
442
+ "WHERE i.state != 'deleted' AND NOT EXISTS (SELECT 1 FROM "
443
+ "repository_effective_policies e WHERE e.account_id = i.account_id AND "
444
+ "e.repository_id = k.repository_id) LIMIT ?",
445
+ (int(limit) - len(work),),
446
+ )
447
+ work += [(int(r["account_id"]), int(r["repository_id"])) for r in missing]
448
+ counts = {"resolved": 0, "failed": 0}
449
+ failed_accounts: dict[int, list[int]] = {}
450
+ for account_id, repository_id in work:
451
+ now = self._now()
452
+ with self._store.transaction() as db:
453
+ db.execute(
454
+ "INSERT INTO repository_effective_policies (account_id, repository_id, state, "
455
+ "invalidated_at) VALUES (?, ?, 'syncing', ?) ON CONFLICT (account_id, "
456
+ "repository_id) DO UPDATE SET state = 'syncing'",
457
+ (account_id, repository_id, ts(now)),
458
+ )
459
+ try:
460
+ with self._store.transaction() as db:
461
+ resolved = self.resolve_in(db, account_id, repository_id)
462
+ self._store_resolution(db, resolved)
463
+ counts["resolved"] += 1
464
+ except Exception as exc: # noqa: BLE001 - one repository must not stop propagation
465
+ log.error(
466
+ "policy_propagation_failed",
467
+ repository_id=repository_id,
468
+ error_type=type(exc).__name__,
469
+ )
470
+ with self._store.transaction() as db:
471
+ db.execute(
472
+ "UPDATE repository_effective_policies SET state = 'error', "
473
+ "attempts = attempts + 1, error = ? WHERE account_id = ? "
474
+ "AND repository_id = ?",
475
+ (f"resolution failed ({type(exc).__name__})", account_id, repository_id),
476
+ )
477
+ counts["failed"] += 1
478
+ failed_accounts.setdefault(account_id, []).append(repository_id)
479
+ for account_id, repositories in failed_accounts.items():
480
+ self._propagation_failed(account_id, repositories)
481
+ return counts
482
+
483
+ def _propagation_failed(self, account_id: int, repositories: list[int]) -> None:
484
+ now = self._now()
485
+ with self._store.transaction() as db:
486
+ stored = self._store.insert_audit_event(
487
+ db,
488
+ self._audit.build(
489
+ AuditEventType.POLICY_PROPAGATION_FAILED,
490
+ actor=SYSTEM_ACTOR,
491
+ account_id=account_id,
492
+ repositories=len(repositories),
493
+ repository_ids=",".join(str(i) for i in repositories[:50]),
494
+ ),
495
+ )
496
+ emit(
497
+ db,
498
+ NotificationEvent(
499
+ type=NotificationType.POLICY_PROPAGATION_FAILED,
500
+ account_id=account_id,
501
+ severity=Severity.HIGH,
502
+ resource_type="organization",
503
+ resource_id=str(account_id),
504
+ dedup_key=domain_key(NotificationType.POLICY_PROPAGATION_FAILED, account_id),
505
+ title="Policy propagation failed for some repositories",
506
+ body=(
507
+ f"CommitGuard could not update the effective policy of {len(repositories)} "
508
+ "repository(ies). The organization policy remains active; scans of these "
509
+ "repositories resolve the policy directly and fail closed if that fails. "
510
+ "See the organization dashboard for details."
511
+ ),
512
+ metadata={"repositories": len(repositories)},
513
+ ),
514
+ now,
515
+ )
516
+ self._audit.log_stored(stored)
517
+
518
+ def propagation_status(self, principal: Principal, account_id: int) -> PropagationStatus:
519
+ require(principal, Permission.POLICIES_READ, account_id)
520
+ repositories = account_repositories(self._store, account_id)
521
+ states = {
522
+ int(r["repository_id"]): (str(r["state"]), r["error"], dt(r["valid_until"]))
523
+ for r in self._store.query(
524
+ "SELECT repository_id, state, error, valid_until FROM "
525
+ "repository_effective_policies WHERE account_id = ?",
526
+ (account_id,),
527
+ )
528
+ }
529
+ now = self._now()
530
+ counts = dict.fromkeys(PROPAGATION_STATES, 0)
531
+ visible = visible_repository_ids(self._store, principal, account_id)
532
+ failing: list[dict[str, object]] = []
533
+ for repository_id, repository in repositories.items():
534
+ state, error, valid_until = states.get(repository_id, ("pending", None, None))
535
+ if state == "up_to_date" and valid_until is not None and valid_until <= now:
536
+ state = "stale" # an included exception expired; not yet re-resolved
537
+ counts[state] += 1
538
+ if state == "error" and repository_id in visible and len(failing) < 50:
539
+ failing.append(
540
+ {
541
+ "repository_id": repository_id,
542
+ "full_name": repository.full_name,
543
+ "error": error,
544
+ }
545
+ )
546
+ return PropagationStatus(
547
+ organization_id=account_id,
548
+ repositories=len(repositories),
549
+ up_to_date=counts["up_to_date"],
550
+ stale=counts["stale"],
551
+ syncing=counts["syncing"],
552
+ error=counts["error"],
553
+ pending=counts["pending"],
554
+ complete=counts["up_to_date"] == len(repositories),
555
+ failing=tuple(failing),
556
+ checked_at=now,
557
+ )
558
+
559
+ def repository_state(self, account_id: int, repository_id: int) -> str:
560
+ rows = self._store.query(
561
+ "SELECT state, valid_until FROM repository_effective_policies WHERE account_id = ? "
562
+ "AND repository_id = ?",
563
+ (account_id, repository_id),
564
+ )
565
+ if not rows:
566
+ return "pending"
567
+ valid_until = dt(rows[0]["valid_until"])
568
+ expired = valid_until is not None and valid_until <= self._now()
569
+ if rows[0]["state"] == "up_to_date" and expired:
570
+ return "stale"
571
+ return str(rows[0]["state"])
572
+
573
+ # -- views ------------------------------------------------------------ #
574
+ def effective_view(
575
+ self, principal: Principal, account_id: int, repository_id: int
576
+ ) -> EffectivePolicyView:
577
+ require(principal, Permission.POLICIES_READ, account_id)
578
+ repository = account_repository(self._store, account_id, repository_id)
579
+ if repository is None or repository_id not in visible_repository_ids(
580
+ self._store, principal, account_id
581
+ ):
582
+ raise NotFoundError()
583
+ resolved = self.for_repository(account_id, repository_id)
584
+ state = self.repository_state(account_id, repository_id)
585
+ effective = resolve_policy(resolved.inputs, None)
586
+ last = self._store.query(
587
+ "SELECT j.job_id, j.completed_at, j.governance, j.governance_fingerprint FROM "
588
+ "scan_jobs j JOIN installations i ON i.installation_id = j.installation_id WHERE "
589
+ "i.account_id = ? AND j.repository_id = ? AND j.state IN ('passed', 'failed') "
590
+ "ORDER BY j.completed_at DESC LIMIT 1",
591
+ (account_id, repository_id),
592
+ )
593
+ last_effective = None
594
+ used_current = None
595
+ if last:
596
+ record = last[0]["governance"]
597
+ if record:
598
+ try:
599
+ parsed = json.loads(str(record))
600
+ last_effective = EffectivePolicy.model_validate(parsed.get("effective"))
601
+ except (ValueError, TypeError):
602
+ last_effective = None
603
+ fingerprint = last[0]["governance_fingerprint"]
604
+ used_current = fingerprint == resolved.fingerprint if fingerprint else False
605
+ return EffectivePolicyView(
606
+ organization_id=account_id,
607
+ repository_id=repository_id,
608
+ full_name=repository.full_name,
609
+ mode=resolved.inputs.mode,
610
+ effective=effective,
611
+ versions=resolved.versions,
612
+ propagation=state,
613
+ resolved_at=resolved.resolved_at,
614
+ last_scan_id=last[0]["job_id"] if last else None,
615
+ last_scan_completed_at=dt(last[0]["completed_at"]) if last else None,
616
+ last_scan_effective=last_effective,
617
+ last_scan_used_current_policy=used_current,
618
+ )
619
+
620
+
621
+ def scan_governance_record(resolved: ResolvedGovernance, effective: EffectivePolicy | None) -> str:
622
+ """The governance record stored with a completed scan (immutable history)."""
623
+ return json.dumps(
624
+ {
625
+ "versions": resolved.versions.model_dump(mode="json"),
626
+ "inputs_fingerprint": resolved.fingerprint,
627
+ "description": resolved.inputs.describe(),
628
+ "mode": resolved.inputs.mode.value,
629
+ "effective": effective.model_dump(mode="json") if effective else None,
630
+ },
631
+ sort_keys=True,
632
+ )