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,120 @@
1
+ """Wiring of the governance services and their background work.
2
+
3
+ Background work (run by the GitHub App's maintenance loop, every minute; each
4
+ task is isolated so one failure never stops the others):
5
+
6
+ ========================= ==============================================================
7
+ Task Work
8
+ ========================= ==============================================================
9
+ exception expiry ACTIVE -> EXPIRED past ``expires_at``; audit, notification,
10
+ invalidation
11
+ exception warnings one notification per configured threshold before expiry
12
+ propagation resolve stale / never-resolved effective policies (bounded)
13
+ rollout safety pause (or, when configured, roll back) rollouts over threshold
14
+ simulations run queued policy simulations (read-only, leased)
15
+ bulk operations process pending items in bounded batches (leased)
16
+ scan schedules start due runs, queue scans within the per-tick budget
17
+ metrics snapshots daily organization metric snapshot (at most hourly refresh)
18
+ ========================= ==============================================================
19
+ """
20
+
21
+ from collections.abc import Callable
22
+ from datetime import UTC, datetime
23
+
24
+ from commitguard.controlplane.policies import OrganizationPolicyService
25
+ from commitguard.controlplane.queries import DashboardQueries
26
+ from commitguard.github.client import GitHubClient
27
+ from commitguard.github.installations import InstallationService
28
+ from commitguard.github.storage import SqliteStateStore
29
+ from commitguard.github.worker import ScanGovernance
30
+ from commitguard.governance.bulk import BulkOperationService
31
+ from commitguard.governance.exceptions import PolicyExceptionService
32
+ from commitguard.governance.groups import RepositoryGroupService
33
+ from commitguard.governance.inventory import RepositoryInventory
34
+ from commitguard.governance.posture import SecurityPostureService
35
+ from commitguard.governance.resolver import GovernanceResolver, scan_governance_record
36
+ from commitguard.governance.rollouts import PolicyRolloutService
37
+ from commitguard.governance.rules import OrganizationRuleService
38
+ from commitguard.governance.schedules import DefaultBranchScanner, ScanScheduleService
39
+ from commitguard.governance.settings import OrganizationSettingsService
40
+ from commitguard.governance.simulation import PolicySimulationService
41
+ from commitguard.governance.workflow import PolicyWorkflowService
42
+ from commitguard.observability.logging import get_logger
43
+ from commitguard.services.audit import AuditService
44
+
45
+ log = get_logger(__name__)
46
+
47
+
48
+ class GovernanceServices:
49
+ def __init__(
50
+ self,
51
+ store: SqliteStateStore,
52
+ audit: AuditService,
53
+ policies: OrganizationPolicyService,
54
+ *,
55
+ installations: InstallationService | None = None,
56
+ client: GitHubClient | None = None,
57
+ enqueue: Callable[[str], bool] | None = None,
58
+ now: Callable[[], datetime] = lambda: datetime.now(UTC),
59
+ ) -> None:
60
+ self.store = store
61
+ self.policies = policies
62
+ self.settings = OrganizationSettingsService(store, audit, now=now)
63
+ self.inventory = RepositoryInventory(store, audit, now=now)
64
+ self.groups = RepositoryGroupService(store, audit, now=now)
65
+ self.exceptions = PolicyExceptionService(store, audit, now=now)
66
+ self.resolver = GovernanceResolver(store, policies, audit, now=now)
67
+ self.rules = OrganizationRuleService(store, audit, now=now)
68
+ self.workflow = PolicyWorkflowService(store, audit, policies, now=now)
69
+ self.rollouts = PolicyRolloutService(store, audit, policies, now=now)
70
+ self.simulations = PolicySimulationService(store, audit, self.resolver, now=now)
71
+ self.scanner = (
72
+ DefaultBranchScanner(store, installations, client, self.resolver, enqueue, now=now)
73
+ if installations is not None and client is not None and enqueue is not None
74
+ else None
75
+ )
76
+ self.schedules = ScanScheduleService(store, audit, self.scanner, now=now)
77
+ self.bulk = BulkOperationService(
78
+ store, audit, self.groups, self.inventory, self.scanner, now=now
79
+ )
80
+ self.posture = SecurityPostureService(
81
+ store, audit, DashboardQueries(store, now=now), now=now
82
+ )
83
+ self.settings.add_hook(self.resolver.on_settings_saved)
84
+ self._now = now
85
+
86
+ def scan_governance(self, installation_id: int, repository_id: int) -> ScanGovernance | None:
87
+ resolved = self.resolver.for_scan(installation_id, repository_id)
88
+ if resolved is None:
89
+ return None
90
+ rules, rules_version = self.rules.compiled(resolved.account_id)
91
+ return ScanGovernance(
92
+ inputs=resolved.inputs,
93
+ organization_policy_version=resolved.versions.organization_policy,
94
+ fingerprint=resolved.fingerprint,
95
+ record=lambda effective: scan_governance_record(resolved, effective),
96
+ rules=rules,
97
+ rules_version=rules_version if rules is not None else None,
98
+ )
99
+
100
+ def run_maintenance(self) -> dict[str, int]:
101
+ """One pass of governance background work. Each task is isolated."""
102
+ results: dict[str, int] = {}
103
+ tasks: dict[str, Callable[[], object]] = {
104
+ "exceptions_expired": self.exceptions.expire_due,
105
+ "exception_warnings": self.exceptions.warn_expiring,
106
+ "rollouts_paused": self.rollouts.evaluate,
107
+ "propagation": lambda: self.resolver.propagate().get("resolved", 0),
108
+ "simulations": self.simulations.run_pending,
109
+ "bulk_items": self.bulk.run_pending,
110
+ "scheduled_scans": lambda: self.schedules.run_due().get("queued", 0),
111
+ "metric_snapshots": self.posture.snapshot_metrics,
112
+ }
113
+ for name, task in tasks.items():
114
+ try:
115
+ value = task()
116
+ results[name] = int(value) if isinstance(value, int) else 0
117
+ except Exception as exc: # noqa: BLE001 - keep other tasks running
118
+ log.error("governance_task_failed", task=name, error_type=type(exc).__name__)
119
+ results[name] = -1
120
+ return results
@@ -0,0 +1,365 @@
1
+ """Organization security settings: versioned, audited, and weakening needs confirmation.
2
+
3
+ ====================================== =========== ==========================================
4
+ Setting Default Effect
5
+ ====================================== =========== ==========================================
6
+ ``security_baseline`` none mandatory rule requirements applied to every
7
+ repository of the organization (a policy
8
+ layer above the organization policy)
9
+ ``require_policy_approval`` off policy changes are drafted, approved, then
10
+ published; direct saves are refused
11
+ ``require_separate_approver`` on the author of a policy draft cannot approve it
12
+ ``exception_approval_min_severity`` ``high`` exceptions for rules at or above this severity
13
+ (and every organization- or group-wide
14
+ exception) need approval by someone else
15
+ ``exception_max_days`` 90 longest allowed exception
16
+ ``allow_permanent_exceptions`` off permanent exceptions need this *and*
17
+ ``exceptions:approve``
18
+ ``exception_warning_days`` 7, 3, 1 expiry warnings (days before expiry)
19
+ ``default_onboarding_mode`` ``enforce`` mode of newly discovered repositories
20
+ ``auto_onboard_new_repositories`` on newly discovered repositories are onboarded
21
+ (else they wait in ``discovered``)
22
+ ``archived_repositories`` ``keep`` ``keep``: stay visible, no scheduled scans;
23
+ ``exclude``: excluded from onboarding
24
+ ``rollout_auto_pause`` on pause a staged rollout that exceeds its
25
+ thresholds
26
+ ``rollout_max_error_rate`` 0.2 share of scan errors among enrolled scans
27
+ ``rollout_max_block_rate`` 0.5 share of blocked scans among enrolled scans
28
+ ``rollout_min_scans`` 5 scans needed before thresholds apply
29
+ ``rollout_auto_rollback`` off roll back (not only pause) on a breach
30
+ ``aggregate_violation_alerts`` off e-mail/webhook violation alerts as one
31
+ organization digest per rule and hour
32
+ ``timezone`` ``UTC`` scan schedules and report dates
33
+ ====================================== =========== ==========================================
34
+
35
+ The default onboarding mode is ``enforce`` so that installing the GitHub App
36
+ keeps its Phase 5 meaning (the check fails on violations). Organizations that
37
+ want to observe first set ``monitor``.
38
+
39
+ A change that relaxes a control - turning approval off, allowing permanent
40
+ exceptions, lowering the baseline, onboarding in monitor mode, turning off
41
+ rollout safety - is a *weakening* change: it needs ``confirm``, a reason and a
42
+ sign-in from the last 15 minutes, and produces a critical notification.
43
+ """
44
+
45
+ import json
46
+ import sqlite3
47
+ from collections.abc import Callable, Mapping
48
+ from dataclasses import dataclass
49
+ from datetime import UTC, datetime
50
+ from typing import Any, Literal
51
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
52
+
53
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
54
+
55
+ from commitguard.audit.models import Actor, AuditEventType
56
+ from commitguard.controlplane.access import Permission, Principal
57
+ from commitguard.controlplane.errors import (
58
+ ConfirmationRequiredError,
59
+ ConflictError,
60
+ InputValidationError,
61
+ ReauthenticationRequiredError,
62
+ )
63
+ from commitguard.controlplane.policies import REAUTHENTICATION_WINDOW
64
+ from commitguard.core.decision import Action
65
+ from commitguard.core.result import Severity
66
+ from commitguard.github.storage import SqliteStateStore
67
+ from commitguard.governance.common import MAX_REASON_CHARS, req_dt, require, text, ts
68
+ from commitguard.notifications.deduplication import domain_key
69
+ from commitguard.notifications.models import NotificationEvent, NotificationType
70
+ from commitguard.notifications.outbox import emit
71
+ from commitguard.policies.defaults import DEFAULT_POLICIES
72
+ from commitguard.policies.governance import RepositoryMode
73
+ from commitguard.services.audit import AuditService
74
+
75
+ type SettingsHook = Callable[[sqlite3.Connection, int, "OrganizationSettings"], None]
76
+
77
+
78
+ class OrganizationSettings(BaseModel):
79
+ model_config = ConfigDict(frozen=True, extra="forbid")
80
+
81
+ security_baseline: dict[str, Action] = {}
82
+ require_policy_approval: bool = False
83
+ require_separate_approver: bool = True
84
+ exception_approval_min_severity: Severity = Severity.HIGH
85
+ exception_max_days: int = Field(default=90, ge=1, le=365)
86
+ allow_permanent_exceptions: bool = False
87
+ exception_warning_days: tuple[int, ...] = (7, 3, 1)
88
+ default_onboarding_mode: RepositoryMode = RepositoryMode.ENFORCE
89
+ auto_onboard_new_repositories: bool = True
90
+ archived_repositories: Literal["keep", "exclude"] = "keep"
91
+ rollout_auto_pause: bool = True
92
+ rollout_max_error_rate: float = Field(default=0.2, ge=0.0, le=1.0)
93
+ rollout_max_block_rate: float = Field(default=0.5, ge=0.0, le=1.0)
94
+ rollout_min_scans: int = Field(default=5, ge=1, le=10_000)
95
+ rollout_auto_rollback: bool = False
96
+ aggregate_violation_alerts: bool = False
97
+ timezone: str = "UTC"
98
+
99
+ @field_validator("security_baseline")
100
+ @classmethod
101
+ def _baseline(cls, value: dict[str, Action]) -> dict[str, Action]:
102
+ for rule, action in value.items():
103
+ if rule not in DEFAULT_POLICIES:
104
+ raise ValueError(f"unknown rule {rule!r} in the security baseline")
105
+ if action is Action.ALLOW:
106
+ raise ValueError("a baseline requirement must be warn or block")
107
+ return dict(sorted(value.items()))
108
+
109
+ @field_validator("exception_warning_days")
110
+ @classmethod
111
+ def _warnings(cls, value: tuple[int, ...]) -> tuple[int, ...]:
112
+ if len(value) > 5 or any(not 1 <= day <= 90 for day in value):
113
+ raise ValueError("at most 5 warning days, each between 1 and 90")
114
+ return tuple(sorted(set(value), reverse=True))
115
+
116
+ @field_validator("timezone")
117
+ @classmethod
118
+ def _timezone(cls, value: str) -> str:
119
+ if len(value) > 64:
120
+ raise ValueError("unknown time zone")
121
+ try:
122
+ ZoneInfo(value)
123
+ except (ZoneInfoNotFoundError, ValueError):
124
+ raise ValueError("unknown time zone") from None
125
+ return value
126
+
127
+
128
+ DEFAULT_SETTINGS = OrganizationSettings()
129
+
130
+
131
+ def weakening_changes(old: OrganizationSettings, new: OrganizationSettings) -> list[str]:
132
+ """Human-readable descriptions of every relaxed control."""
133
+ changes = []
134
+ for rule, action in old.security_baseline.items():
135
+ after = new.security_baseline.get(rule)
136
+ if after is None or after.rank < action.rank:
137
+ changes.append(f"security baseline {rule}: {action.value} -> {after or 'removed'}")
138
+ if old.require_policy_approval and not new.require_policy_approval:
139
+ changes.append("policy approval no longer required")
140
+ if old.require_separate_approver and not new.require_separate_approver:
141
+ changes.append("authors may approve their own policy changes")
142
+ if new.exception_approval_min_severity.rank > old.exception_approval_min_severity.rank:
143
+ changes.append(
144
+ "fewer exceptions need approval "
145
+ f"({old.exception_approval_min_severity.value} -> "
146
+ f"{new.exception_approval_min_severity.value})"
147
+ )
148
+ if new.exception_max_days > old.exception_max_days:
149
+ changes.append(
150
+ f"longer exceptions allowed ({old.exception_max_days} -> {new.exception_max_days} days)"
151
+ )
152
+ if new.allow_permanent_exceptions and not old.allow_permanent_exceptions:
153
+ changes.append("permanent exceptions allowed")
154
+ if (
155
+ old.default_onboarding_mode is RepositoryMode.ENFORCE
156
+ and new.default_onboarding_mode is RepositoryMode.MONITOR
157
+ ):
158
+ changes.append("new repositories onboard in monitor mode (not blocking)")
159
+ if old.rollout_auto_pause and not new.rollout_auto_pause:
160
+ changes.append("staged rollouts no longer pause automatically")
161
+ if new.rollout_max_error_rate > old.rollout_max_error_rate:
162
+ changes.append("higher rollout error threshold")
163
+ if new.rollout_max_block_rate > old.rollout_max_block_rate:
164
+ changes.append("higher rollout block threshold")
165
+ return changes
166
+
167
+
168
+ @dataclass(frozen=True, slots=True)
169
+ class StoredSettings:
170
+ account_id: int
171
+ version: int # 0: defaults, never saved
172
+ settings: OrganizationSettings
173
+ updated_at: datetime | None
174
+ updated_by: str | None
175
+
176
+
177
+ class SettingsView(BaseModel):
178
+ model_config = ConfigDict(frozen=True, extra="forbid")
179
+
180
+ organization_id: int
181
+ version: int
182
+ settings: OrganizationSettings
183
+ updated_at: datetime | None
184
+ updated_by: str | None
185
+ can_manage: bool
186
+
187
+
188
+ def load_settings(db: sqlite3.Connection | SqliteStateStore, account_id: int) -> StoredSettings:
189
+ sql = "SELECT * FROM organization_settings WHERE account_id = ?"
190
+ if isinstance(db, sqlite3.Connection):
191
+ row = db.execute(sql, (int(account_id),)).fetchone()
192
+ else:
193
+ rows = db.query(sql, (int(account_id),))
194
+ row = rows[0] if rows else None
195
+ if row is None:
196
+ return StoredSettings(int(account_id), 0, DEFAULT_SETTINGS, None, None)
197
+ try:
198
+ settings = OrganizationSettings.model_validate_json(str(row["document"]))
199
+ except ValidationError:
200
+ # A stored document that no longer validates must not silently relax
201
+ # anything: fall back to defaults, which are the documented baseline.
202
+ settings = DEFAULT_SETTINGS
203
+ return StoredSettings(
204
+ int(account_id),
205
+ int(row["version"]),
206
+ settings,
207
+ req_dt(row["updated_at"]),
208
+ row["updated_by_login"],
209
+ )
210
+
211
+
212
+ class OrganizationSettingsService:
213
+ def __init__(
214
+ self,
215
+ store: SqliteStateStore,
216
+ audit: AuditService,
217
+ *,
218
+ now: Callable[[], datetime] = lambda: datetime.now(UTC),
219
+ ) -> None:
220
+ self._store = store
221
+ self._audit = audit
222
+ self._now = now
223
+ self._hooks: list[SettingsHook] = []
224
+
225
+ def add_hook(self, hook: SettingsHook) -> None:
226
+ """Run ``hook`` inside the saving transaction (e.g. effective policy invalidation)."""
227
+ self._hooks.append(hook)
228
+
229
+ def get(self, account_id: int) -> StoredSettings:
230
+ return load_settings(self._store, account_id)
231
+
232
+ def view(self, principal: Principal, account_id: int) -> SettingsView:
233
+ require(principal, Permission.ORGANIZATION_READ, account_id)
234
+ stored = self.get(account_id)
235
+ return SettingsView(
236
+ organization_id=account_id,
237
+ version=stored.version,
238
+ settings=stored.settings,
239
+ updated_at=stored.updated_at,
240
+ updated_by=stored.updated_by,
241
+ can_manage=principal.can(Permission.ORGANIZATION_MANAGE, account_id),
242
+ )
243
+
244
+ def update(
245
+ self,
246
+ principal: Principal,
247
+ account_id: int,
248
+ *,
249
+ expected_version: object,
250
+ changes: object,
251
+ reason: object,
252
+ confirm: object,
253
+ ) -> SettingsView:
254
+ require(principal, Permission.ORGANIZATION_MANAGE, account_id)
255
+ if (
256
+ not isinstance(expected_version, int)
257
+ or isinstance(expected_version, bool)
258
+ or expected_version < 0
259
+ ):
260
+ raise InputValidationError(
261
+ "expected_version must be a non-negative integer", field="expected_version"
262
+ )
263
+ if not isinstance(changes, Mapping) or not changes:
264
+ raise InputValidationError("settings must be a non-empty object", field="settings")
265
+ if not isinstance(confirm, bool):
266
+ raise InputValidationError("confirm must be true or false", field="confirm")
267
+ reason_text = text(reason, "reason", limit=MAX_REASON_CHARS)
268
+ now = self._now()
269
+ current = self.get(account_id)
270
+ if current.version != expected_version:
271
+ raise ConflictError(
272
+ f"The settings were changed by someone else (now version {current.version}). "
273
+ "Reload before saving."
274
+ )
275
+ merged: dict[str, Any] = {**current.settings.model_dump(mode="json"), **dict(changes)}
276
+ try:
277
+ updated = OrganizationSettings.model_validate(merged)
278
+ except ValidationError as exc:
279
+ error = exc.errors()[0]
280
+ location = ".".join(str(part) for part in error.get("loc", ()))
281
+ raise InputValidationError(
282
+ f"Invalid setting {location or 'value'}: {error.get('msg', 'invalid value')}",
283
+ field=f"settings.{location}" if location else "settings",
284
+ ) from None
285
+ changed = sorted(
286
+ key
287
+ for key in type(updated).model_fields
288
+ if getattr(updated, key) != getattr(current.settings, key)
289
+ )
290
+ if not changed:
291
+ raise InputValidationError("The settings are identical to the current version.")
292
+ weakening = weakening_changes(current.settings, updated)
293
+ if weakening:
294
+ if not confirm:
295
+ raise ConfirmationRequiredError(
296
+ "This change relaxes security controls and must be confirmed: "
297
+ + "; ".join(weakening),
298
+ changes=tuple(weakening),
299
+ )
300
+ if now - principal.authenticated_at > REAUTHENTICATION_WINDOW:
301
+ raise ReauthenticationRequiredError()
302
+ if reason_text is None:
303
+ raise InputValidationError(
304
+ "A reason is required when relaxing security controls.", field="reason"
305
+ )
306
+ actor = Actor.user(principal.user_id, principal.login)
307
+ document = updated.model_dump_json()
308
+ with self._store.transaction() as db:
309
+ row = db.execute(
310
+ "SELECT version FROM organization_settings WHERE account_id = ?", (account_id,)
311
+ ).fetchone()
312
+ latest = int(row["version"]) if row else 0
313
+ if latest != expected_version:
314
+ raise ConflictError(
315
+ f"The settings were changed by someone else (now version {latest}). "
316
+ "Reload before saving."
317
+ )
318
+ db.execute(
319
+ "INSERT INTO organization_settings (account_id, version, document, updated_at, "
320
+ "updated_by_id, updated_by_login) VALUES (?, ?, ?, ?, ?, ?) "
321
+ "ON CONFLICT (account_id) DO UPDATE SET version = excluded.version, "
322
+ "document = excluded.document, updated_at = excluded.updated_at, "
323
+ "updated_by_id = excluded.updated_by_id, "
324
+ "updated_by_login = excluded.updated_by_login",
325
+ (account_id, latest + 1, document, ts(now), actor.id, actor.login),
326
+ )
327
+ stored = self._store.insert_audit_event(
328
+ db,
329
+ self._audit.build(
330
+ AuditEventType.ORGANIZATION_SETTINGS_CHANGED,
331
+ actor=actor,
332
+ account_id=account_id,
333
+ old_version=latest,
334
+ new_version=latest + 1,
335
+ changed=",".join(changed),
336
+ weakening="; ".join(weakening) or None,
337
+ baseline=json.dumps({k: v.value for k, v in updated.security_baseline.items()}),
338
+ reason=reason_text,
339
+ ),
340
+ )
341
+ emit(
342
+ db,
343
+ NotificationEvent(
344
+ type=NotificationType.ORGANIZATION_SETTINGS_CHANGED,
345
+ account_id=account_id,
346
+ severity=Severity.CRITICAL if weakening else Severity.MEDIUM,
347
+ resource_type="organization",
348
+ resource_id=str(account_id),
349
+ dedup_key=domain_key(
350
+ NotificationType.ORGANIZATION_SETTINGS_CHANGED, account_id, latest + 1
351
+ ),
352
+ title=f"Organization security settings changed (v{latest + 1})",
353
+ body=(
354
+ f"{actor.login} changed: {', '.join(changed)}."
355
+ + (f" Relaxed controls: {'; '.join(weakening)}." if weakening else "")
356
+ + (f" Reason: {reason_text}" if reason_text else "")
357
+ ),
358
+ metadata={"new_version": latest + 1, "weakening": bool(weakening)},
359
+ ),
360
+ now,
361
+ )
362
+ for hook in self._hooks:
363
+ hook(db, account_id, updated)
364
+ self._audit.log_stored(stored)
365
+ return self.view(principal, account_id)