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.
- commitguard/__init__.py +26 -0
- commitguard/__main__.py +6 -0
- commitguard/api/__init__.py +18 -0
- commitguard/api/app.py +1376 -0
- commitguard/api/governance.py +1085 -0
- commitguard/api/hosting.py +196 -0
- commitguard/api/http.py +252 -0
- commitguard/api/settings.py +169 -0
- commitguard/audit/__init__.py +13 -0
- commitguard/audit/logger.py +34 -0
- commitguard/audit/models.py +222 -0
- commitguard/audit/storage.py +59 -0
- commitguard/ci/__init__.py +7 -0
- commitguard/ci/context.py +60 -0
- commitguard/cli/__init__.py +6 -0
- commitguard/cli/app.py +74 -0
- commitguard/cli/commands/__init__.py +1 -0
- commitguard/cli/commands/benchmark.py +441 -0
- commitguard/cli/commands/check.py +100 -0
- commitguard/cli/commands/ci.py +165 -0
- commitguard/cli/commands/dashboard.py +141 -0
- commitguard/cli/commands/doctor.py +533 -0
- commitguard/cli/commands/github.py +449 -0
- commitguard/cli/commands/hook.py +156 -0
- commitguard/cli/commands/init.py +137 -0
- commitguard/cli/commands/install.py +152 -0
- commitguard/cli/commands/policy.py +36 -0
- commitguard/cli/commands/report.py +39 -0
- commitguard/cli/commands/reproduce.py +123 -0
- commitguard/cli/commands/scan.py +47 -0
- commitguard/cli/common.py +44 -0
- commitguard/cli/output.py +89 -0
- commitguard/cli/render.py +367 -0
- commitguard/config/__init__.py +6 -0
- commitguard/config/defaults.py +53 -0
- commitguard/config/enforcement.py +53 -0
- commitguard/config/loader.py +174 -0
- commitguard/config/schema.py +105 -0
- commitguard/config/sources.py +183 -0
- commitguard/controlplane/__init__.py +24 -0
- commitguard/controlplane/access.py +231 -0
- commitguard/controlplane/commands.py +393 -0
- commitguard/controlplane/errors.py +88 -0
- commitguard/controlplane/identity.py +478 -0
- commitguard/controlplane/members.py +219 -0
- commitguard/controlplane/notifications.py +787 -0
- commitguard/controlplane/pagination.py +146 -0
- commitguard/controlplane/policies.py +1204 -0
- commitguard/controlplane/queries.py +1814 -0
- commitguard/controlplane/results.py +909 -0
- commitguard/controlplane/rules.py +184 -0
- commitguard/controlplane/views.py +799 -0
- commitguard/core/__init__.py +6 -0
- commitguard/core/context.py +31 -0
- commitguard/core/decision.py +58 -0
- commitguard/core/engine.py +82 -0
- commitguard/core/result.py +177 -0
- commitguard/detectors/__init__.py +6 -0
- commitguard/detectors/base.py +58 -0
- commitguard/detectors/bot.py +87 -0
- commitguard/detectors/coauthor.py +86 -0
- commitguard/detectors/identity.py +76 -0
- commitguard/detectors/registry.py +72 -0
- commitguard/detectors/trailer.py +211 -0
- commitguard/exceptions/__init__.py +33 -0
- commitguard/exceptions/base.py +9 -0
- commitguard/exceptions/configuration.py +22 -0
- commitguard/exceptions/detection.py +11 -0
- commitguard/exceptions/git.py +41 -0
- commitguard/exceptions/service.py +25 -0
- commitguard/git/__init__.py +12 -0
- commitguard/git/commands.py +101 -0
- commitguard/git/commit.py +97 -0
- commitguard/git/diff.py +36 -0
- commitguard/git/hooks.py +527 -0
- commitguard/git/push.py +93 -0
- commitguard/git/ranges.py +71 -0
- commitguard/git/repository.py +447 -0
- commitguard/github/__init__.py +34 -0
- commitguard/github/actions.py +163 -0
- commitguard/github/app.py +935 -0
- commitguard/github/auth.py +217 -0
- commitguard/github/check_runs.py +172 -0
- commitguard/github/checks.py +210 -0
- commitguard/github/client.py +844 -0
- commitguard/github/enforcement_status.py +209 -0
- commitguard/github/errors.py +129 -0
- commitguard/github/events.py +563 -0
- commitguard/github/identifiers.py +90 -0
- commitguard/github/installations.py +566 -0
- commitguard/github/markdown.py +19 -0
- commitguard/github/permissions.py +70 -0
- commitguard/github/pull_requests.py +53 -0
- commitguard/github/queue.py +47 -0
- commitguard/github/recovery.py +124 -0
- commitguard/github/repositories.py +305 -0
- commitguard/github/server.py +52 -0
- commitguard/github/settings.py +174 -0
- commitguard/github/storage.py +2315 -0
- commitguard/github/webhooks.py +129 -0
- commitguard/github/worker.py +628 -0
- commitguard/github/workflow.py +286 -0
- commitguard/governance/__init__.py +26 -0
- commitguard/governance/bulk.py +765 -0
- commitguard/governance/cache.py +88 -0
- commitguard/governance/common.py +216 -0
- commitguard/governance/exceptions.py +861 -0
- commitguard/governance/groups.py +448 -0
- commitguard/governance/inventory.py +386 -0
- commitguard/governance/posture.py +1272 -0
- commitguard/governance/resolver.py +632 -0
- commitguard/governance/rollouts.py +760 -0
- commitguard/governance/rules.py +371 -0
- commitguard/governance/schedules.py +663 -0
- commitguard/governance/service.py +120 -0
- commitguard/governance/settings.py +365 -0
- commitguard/governance/simulation.py +618 -0
- commitguard/governance/workflow.py +734 -0
- commitguard/notifications/__init__.py +2 -0
- commitguard/notifications/channels/__init__.py +1 -0
- commitguard/notifications/channels/base.py +22 -0
- commitguard/notifications/channels/email.py +110 -0
- commitguard/notifications/channels/in_app.py +74 -0
- commitguard/notifications/channels/sink.py +58 -0
- commitguard/notifications/channels/webhook.py +233 -0
- commitguard/notifications/deduplication.py +57 -0
- commitguard/notifications/dispatcher.py +201 -0
- commitguard/notifications/models.py +439 -0
- commitguard/notifications/outbox.py +106 -0
- commitguard/notifications/preferences.py +224 -0
- commitguard/notifications/retry.py +282 -0
- commitguard/notifications/service.py +128 -0
- commitguard/notifications/settings.py +167 -0
- commitguard/notifications/templates.py +108 -0
- commitguard/observability/__init__.py +5 -0
- commitguard/observability/logging.py +161 -0
- commitguard/observability/metrics.py +105 -0
- commitguard/policies/__init__.py +6 -0
- commitguard/policies/defaults.py +48 -0
- commitguard/policies/evaluator.py +66 -0
- commitguard/policies/governance.py +498 -0
- commitguard/policies/loader.py +23 -0
- commitguard/policies/mandatory.py +52 -0
- commitguard/policies/model.py +46 -0
- commitguard/provenance/__init__.py +9 -0
- commitguard/provenance/author.py +146 -0
- commitguard/provenance/committer.py +16 -0
- commitguard/provenance/normalization.py +158 -0
- commitguard/provenance/signatures.py +34 -0
- commitguard/provenance/trailers.py +256 -0
- commitguard/research/__init__.py +26 -0
- commitguard/research/compare.py +231 -0
- commitguard/research/datasets.py +1484 -0
- commitguard/research/detection.py +183 -0
- commitguard/research/environment.py +185 -0
- commitguard/research/gitenv.py +108 -0
- commitguard/research/hooks.py +247 -0
- commitguard/research/metrics.py +85 -0
- commitguard/research/performance.py +194 -0
- commitguard/research/platform.py +288 -0
- commitguard/research/report.py +372 -0
- commitguard/research/repository.py +111 -0
- commitguard/research/reproduction.py +297 -0
- commitguard/research/results.py +94 -0
- commitguard/rules/__init__.py +11 -0
- commitguard/rules/data/ai-domains.yaml +51 -0
- commitguard/rules/data/ai-identities.yaml +131 -0
- commitguard/rules/data/bot-identities.yaml +53 -0
- commitguard/rules/data/patterns.yaml +52 -0
- commitguard/rules/loader.py +102 -0
- commitguard/rules/matcher.py +212 -0
- commitguard/rules/models.py +269 -0
- commitguard/security/__init__.py +5 -0
- commitguard/security/hashing.py +30 -0
- commitguard/security/rate_limit.py +33 -0
- commitguard/security/safe_yaml.py +69 -0
- commitguard/security/sanitization.py +85 -0
- commitguard/security/secrets.py +169 -0
- commitguard/security/validation.py +89 -0
- commitguard/services/__init__.py +15 -0
- commitguard/services/analysis.py +119 -0
- commitguard/services/audit.py +95 -0
- commitguard/services/ci.py +383 -0
- commitguard/services/enforcement.py +102 -0
- commitguard/services/hooks.py +254 -0
- commitguard/services/remediation.py +99 -0
- commitguard/services/reports.py +146 -0
- commitguard/services/scan.py +172 -0
- commitguard/utils/__init__.py +1 -0
- commitguard/utils/filesystem.py +72 -0
- commitguard/utils/platform.py +35 -0
- commitguard/utils/subprocess.py +84 -0
- commitguardian-0.1.0.dist-info/METADATA +694 -0
- commitguardian-0.1.0.dist-info/RECORD +197 -0
- commitguardian-0.1.0.dist-info/WHEEL +4 -0
- commitguardian-0.1.0.dist-info/entry_points.txt +2 -0
- commitguardian-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""Repository onboarding: discovery, onboarding state and enforcement mode.
|
|
2
|
+
|
|
3
|
+
A repository's state has several independent dimensions, and they are never
|
|
4
|
+
merged into one label:
|
|
5
|
+
|
|
6
|
+
============== ===================================================================
|
|
7
|
+
Dimension Values
|
|
8
|
+
============== ===================================================================
|
|
9
|
+
connection ``connected`` (installation active, repository listed) /
|
|
10
|
+
``suspended`` / ``disconnected``
|
|
11
|
+
archived GitHub's ``archived`` flag, from the API
|
|
12
|
+
onboarding ``discovered`` (seen, not yet confirmed by an administrator) /
|
|
13
|
+
``onboarded`` / ``excluded`` (archived repositories, when the
|
|
14
|
+
organization excludes them)
|
|
15
|
+
mode ``enforce`` (block according to policy) / ``monitor`` (scan, record
|
|
16
|
+
and alert, but report ``block`` as ``warn`` so checks do not fail)
|
|
17
|
+
enforcement ``protected`` / ``at_risk`` / ``unprotected`` / ... (evidence from
|
|
18
|
+
GitHub, :func:`commitguard.controlplane.queries.protection_for`)
|
|
19
|
+
policy effective policy propagation: ``up_to_date`` / ``stale`` /
|
|
20
|
+
``syncing`` / ``error`` / ``pending``
|
|
21
|
+
============== ===================================================================
|
|
22
|
+
|
|
23
|
+
So "connected but unprotected, onboarded, monitor" is a valid combination.
|
|
24
|
+
|
|
25
|
+
Organization governance applies to every repository of the organization
|
|
26
|
+
whatever its onboarding state: not onboarding a repository never removes a
|
|
27
|
+
mandatory requirement. A repository with no onboarding row yet (discovered
|
|
28
|
+
before the discovery listener ran) uses the organization's default mode.
|
|
29
|
+
|
|
30
|
+
Discovery: GitHub's installation webhooks and repository synchronisation report
|
|
31
|
+
repositories by immutable ID; :meth:`RepositoryInventory.discovered` records them
|
|
32
|
+
with the organization's defaults. Changing the mode needs ``repositories:manage``
|
|
33
|
+
and ``confirm``: switching to ``enforce`` can make GitHub checks fail and stop
|
|
34
|
+
merges; switching to ``monitor`` stops blocking and also needs a reason.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
import json
|
|
38
|
+
import sqlite3
|
|
39
|
+
from collections.abc import Callable, Collection, Sequence
|
|
40
|
+
from dataclasses import dataclass
|
|
41
|
+
from datetime import UTC, datetime
|
|
42
|
+
|
|
43
|
+
from commitguard.audit.models import SYSTEM_ACTOR, Actor, AuditEvent, AuditEventType
|
|
44
|
+
from commitguard.controlplane.access import Permission, Principal
|
|
45
|
+
from commitguard.controlplane.errors import ConfirmationRequiredError, InputValidationError
|
|
46
|
+
from commitguard.github.identifiers import RepositoryRef
|
|
47
|
+
from commitguard.github.storage import SqliteStateStore
|
|
48
|
+
from commitguard.governance.cache import invalidate_repositories
|
|
49
|
+
from commitguard.governance.common import (
|
|
50
|
+
MAX_REASON_CHARS,
|
|
51
|
+
account_repositories,
|
|
52
|
+
require,
|
|
53
|
+
require_visible_repositories,
|
|
54
|
+
text,
|
|
55
|
+
ts,
|
|
56
|
+
)
|
|
57
|
+
from commitguard.governance.settings import load_settings
|
|
58
|
+
from commitguard.observability.logging import get_logger
|
|
59
|
+
from commitguard.policies.governance import RepositoryMode
|
|
60
|
+
from commitguard.services.audit import AuditService
|
|
61
|
+
|
|
62
|
+
log = get_logger(__name__)
|
|
63
|
+
|
|
64
|
+
ENFORCE_WARNING = (
|
|
65
|
+
"Enforce mode makes CommitGuard checks fail on policy violations. Where branch "
|
|
66
|
+
"protection requires the check, this prevents merges."
|
|
67
|
+
)
|
|
68
|
+
MONITOR_WARNING = (
|
|
69
|
+
"Monitor mode stops CommitGuard from blocking: violations are recorded and alerted, "
|
|
70
|
+
"but checks report them as warnings."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True, slots=True)
|
|
75
|
+
class RepositoryGovernance:
|
|
76
|
+
repository_id: int
|
|
77
|
+
onboarding: str
|
|
78
|
+
mode: RepositoryMode
|
|
79
|
+
recorded: bool # False: no row yet, organization defaults shown
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def governance_state(
|
|
83
|
+
db: sqlite3.Connection | SqliteStateStore, account_id: int, repository_id: int
|
|
84
|
+
) -> RepositoryGovernance:
|
|
85
|
+
sql = (
|
|
86
|
+
"SELECT onboarding, mode FROM repository_governance WHERE account_id = ? "
|
|
87
|
+
"AND repository_id = ?"
|
|
88
|
+
)
|
|
89
|
+
params = (int(account_id), int(repository_id))
|
|
90
|
+
if isinstance(db, sqlite3.Connection):
|
|
91
|
+
row = db.execute(sql, params).fetchone()
|
|
92
|
+
else:
|
|
93
|
+
rows = db.query(sql, params)
|
|
94
|
+
row = rows[0] if rows else None
|
|
95
|
+
if row is not None:
|
|
96
|
+
return RepositoryGovernance(
|
|
97
|
+
int(repository_id), str(row["onboarding"]), RepositoryMode(row["mode"]), True
|
|
98
|
+
)
|
|
99
|
+
settings = load_settings(db, account_id).settings
|
|
100
|
+
return RepositoryGovernance(
|
|
101
|
+
int(repository_id), "discovered", settings.default_onboarding_mode, False
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def governance_states(store: SqliteStateStore, account_id: int) -> dict[int, RepositoryGovernance]:
|
|
106
|
+
return {
|
|
107
|
+
int(row["repository_id"]): RepositoryGovernance(
|
|
108
|
+
int(row["repository_id"]), str(row["onboarding"]), RepositoryMode(row["mode"]), True
|
|
109
|
+
)
|
|
110
|
+
for row in store.query(
|
|
111
|
+
"SELECT repository_id, onboarding, mode FROM repository_governance "
|
|
112
|
+
"WHERE account_id = ?",
|
|
113
|
+
(int(account_id),),
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class RepositoryInventory:
|
|
119
|
+
def __init__(
|
|
120
|
+
self,
|
|
121
|
+
store: SqliteStateStore,
|
|
122
|
+
audit: AuditService,
|
|
123
|
+
*,
|
|
124
|
+
now: Callable[[], datetime] = lambda: datetime.now(UTC),
|
|
125
|
+
) -> None:
|
|
126
|
+
self._store = store
|
|
127
|
+
self._audit = audit
|
|
128
|
+
self._now = now
|
|
129
|
+
|
|
130
|
+
# -- discovery (GitHub events and synchronisation) -------------------- #
|
|
131
|
+
def discovered(
|
|
132
|
+
self, account_id: int, installation_id: int, repositories: Sequence[RepositoryRef]
|
|
133
|
+
) -> int:
|
|
134
|
+
"""Record newly seen repositories with the organization's onboarding defaults."""
|
|
135
|
+
now = self._now()
|
|
136
|
+
stored: list[AuditEvent] = []
|
|
137
|
+
with self._store.transaction() as db:
|
|
138
|
+
settings = load_settings(db, account_id).settings
|
|
139
|
+
known = account_repositories(db, account_id)
|
|
140
|
+
new_ids: list[int] = []
|
|
141
|
+
onboarded: list[int] = []
|
|
142
|
+
excluded: list[int] = []
|
|
143
|
+
for repository in repositories:
|
|
144
|
+
onboarding = "onboarded" if settings.auto_onboard_new_repositories else "discovered"
|
|
145
|
+
info = known.get(repository.id)
|
|
146
|
+
exclude_archived = settings.archived_repositories == "exclude"
|
|
147
|
+
if info is not None and info.archived and exclude_archived:
|
|
148
|
+
onboarding = "excluded"
|
|
149
|
+
cursor = db.execute(
|
|
150
|
+
"INSERT OR IGNORE INTO repository_governance (account_id, repository_id, "
|
|
151
|
+
"onboarding, mode, discovered_at, onboarded_at, onboarded_by, updated_at) "
|
|
152
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
153
|
+
(
|
|
154
|
+
account_id,
|
|
155
|
+
repository.id,
|
|
156
|
+
onboarding,
|
|
157
|
+
settings.default_onboarding_mode.value,
|
|
158
|
+
ts(now),
|
|
159
|
+
ts(now) if onboarding == "onboarded" else None,
|
|
160
|
+
"organization default" if onboarding == "onboarded" else None,
|
|
161
|
+
ts(now),
|
|
162
|
+
),
|
|
163
|
+
)
|
|
164
|
+
if cursor.rowcount:
|
|
165
|
+
new_ids.append(repository.id)
|
|
166
|
+
if onboarding == "onboarded":
|
|
167
|
+
onboarded.append(repository.id)
|
|
168
|
+
elif onboarding == "excluded":
|
|
169
|
+
excluded.append(repository.id)
|
|
170
|
+
if new_ids:
|
|
171
|
+
stored.append(
|
|
172
|
+
self._store.insert_audit_event(
|
|
173
|
+
db,
|
|
174
|
+
self._audit.build(
|
|
175
|
+
AuditEventType.REPOSITORY_DISCOVERED,
|
|
176
|
+
actor=SYSTEM_ACTOR,
|
|
177
|
+
account_id=account_id,
|
|
178
|
+
installation_id=installation_id,
|
|
179
|
+
repositories=len(new_ids),
|
|
180
|
+
repository_ids=",".join(str(i) for i in new_ids[:50]),
|
|
181
|
+
mode=settings.default_onboarding_mode.value,
|
|
182
|
+
),
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
if onboarded:
|
|
186
|
+
stored.append(
|
|
187
|
+
self._store.insert_audit_event(
|
|
188
|
+
db,
|
|
189
|
+
self._audit.build(
|
|
190
|
+
AuditEventType.REPOSITORY_ONBOARDED,
|
|
191
|
+
actor=SYSTEM_ACTOR,
|
|
192
|
+
account_id=account_id,
|
|
193
|
+
installation_id=installation_id,
|
|
194
|
+
repositories=len(onboarded),
|
|
195
|
+
mode=settings.default_onboarding_mode.value,
|
|
196
|
+
source="organization default",
|
|
197
|
+
),
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
if excluded:
|
|
201
|
+
stored.append(
|
|
202
|
+
self._store.insert_audit_event(
|
|
203
|
+
db,
|
|
204
|
+
self._audit.build(
|
|
205
|
+
AuditEventType.REPOSITORY_EXCLUDED,
|
|
206
|
+
actor=SYSTEM_ACTOR,
|
|
207
|
+
account_id=account_id,
|
|
208
|
+
repositories=len(excluded),
|
|
209
|
+
reason="archived repository",
|
|
210
|
+
),
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
for event in stored:
|
|
214
|
+
self._audit.log_stored(event)
|
|
215
|
+
return len(new_ids)
|
|
216
|
+
|
|
217
|
+
# -- administrator actions -------------------------------------------- #
|
|
218
|
+
def onboard(
|
|
219
|
+
self,
|
|
220
|
+
principal: Principal,
|
|
221
|
+
account_id: int,
|
|
222
|
+
repository_ids: Sequence[int],
|
|
223
|
+
*,
|
|
224
|
+
mode: object,
|
|
225
|
+
confirm: object,
|
|
226
|
+
reason: object = None,
|
|
227
|
+
) -> list[int]:
|
|
228
|
+
"""Onboard repositories in ``mode``. Returns the repositories that changed."""
|
|
229
|
+
require(principal, Permission.REPOSITORIES_MANAGE, account_id)
|
|
230
|
+
target = self.parse_mode(mode)
|
|
231
|
+
ids = require_visible_repositories(self._store, principal, account_id, repository_ids)
|
|
232
|
+
reason_text = text(reason, "reason", limit=MAX_REASON_CHARS)
|
|
233
|
+
self._confirm_mode(account_id, ids, target, confirm, reason_text)
|
|
234
|
+
actor = Actor.user(principal.user_id, principal.login)
|
|
235
|
+
with self._store.transaction() as db:
|
|
236
|
+
changed, events = self.apply_in(
|
|
237
|
+
db, account_id, ids, mode=target, actor=actor, onboard=True, reason=reason_text
|
|
238
|
+
)
|
|
239
|
+
for event in events:
|
|
240
|
+
self._audit.log_stored(event)
|
|
241
|
+
return changed
|
|
242
|
+
|
|
243
|
+
@staticmethod
|
|
244
|
+
def parse_mode(mode: object) -> RepositoryMode:
|
|
245
|
+
if not isinstance(mode, str) or mode not in {m.value for m in RepositoryMode}:
|
|
246
|
+
raise InputValidationError("mode must be monitor or enforce", field="mode")
|
|
247
|
+
return RepositoryMode(mode)
|
|
248
|
+
|
|
249
|
+
def _confirm_mode(
|
|
250
|
+
self,
|
|
251
|
+
account_id: int,
|
|
252
|
+
ids: Sequence[int],
|
|
253
|
+
target: RepositoryMode,
|
|
254
|
+
confirm: object,
|
|
255
|
+
reason: str | None,
|
|
256
|
+
) -> None:
|
|
257
|
+
current = {i: governance_state(self._store, account_id, i).mode for i in ids}
|
|
258
|
+
changing = [i for i, m in current.items() if m is not target]
|
|
259
|
+
if not changing:
|
|
260
|
+
return
|
|
261
|
+
if confirm is not True:
|
|
262
|
+
raise ConfirmationRequiredError(
|
|
263
|
+
ENFORCE_WARNING if target is RepositoryMode.ENFORCE else MONITOR_WARNING
|
|
264
|
+
)
|
|
265
|
+
if target is RepositoryMode.MONITOR and reason is None:
|
|
266
|
+
raise InputValidationError(
|
|
267
|
+
"A reason is required to stop blocking (monitor mode).", field="reason"
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def set_mode(
|
|
271
|
+
self,
|
|
272
|
+
principal: Principal,
|
|
273
|
+
account_id: int,
|
|
274
|
+
repository_ids: Sequence[int],
|
|
275
|
+
*,
|
|
276
|
+
mode: object,
|
|
277
|
+
confirm: object,
|
|
278
|
+
reason: object = None,
|
|
279
|
+
) -> list[int]:
|
|
280
|
+
require(principal, Permission.REPOSITORIES_MANAGE, account_id)
|
|
281
|
+
target = self.parse_mode(mode)
|
|
282
|
+
ids = require_visible_repositories(self._store, principal, account_id, repository_ids)
|
|
283
|
+
reason_text = text(reason, "reason", limit=MAX_REASON_CHARS)
|
|
284
|
+
self._confirm_mode(account_id, ids, target, confirm, reason_text)
|
|
285
|
+
actor = Actor.user(principal.user_id, principal.login)
|
|
286
|
+
with self._store.transaction() as db:
|
|
287
|
+
changed, events = self.apply_in(
|
|
288
|
+
db, account_id, ids, mode=target, actor=actor, onboard=False, reason=reason_text
|
|
289
|
+
)
|
|
290
|
+
for event in events:
|
|
291
|
+
self._audit.log_stored(event)
|
|
292
|
+
return changed
|
|
293
|
+
|
|
294
|
+
def apply_in(
|
|
295
|
+
self,
|
|
296
|
+
db: sqlite3.Connection,
|
|
297
|
+
account_id: int,
|
|
298
|
+
repository_ids: Sequence[int],
|
|
299
|
+
*,
|
|
300
|
+
mode: RepositoryMode | None,
|
|
301
|
+
actor: Actor,
|
|
302
|
+
onboard: bool,
|
|
303
|
+
reason: str | None = None,
|
|
304
|
+
known: Collection[int] | None = None,
|
|
305
|
+
) -> tuple[list[int], list[AuditEvent]]:
|
|
306
|
+
"""Onboard and/or set the mode inside a caller's transaction (bulk operations too)."""
|
|
307
|
+
now = self._now()
|
|
308
|
+
if known is None:
|
|
309
|
+
known = account_repositories(db, account_id).keys()
|
|
310
|
+
changed_mode: list[int] = []
|
|
311
|
+
newly_onboarded: list[int] = []
|
|
312
|
+
for repository_id in repository_ids:
|
|
313
|
+
if repository_id not in known:
|
|
314
|
+
continue
|
|
315
|
+
current = governance_state(db, account_id, repository_id)
|
|
316
|
+
target_mode = mode or current.mode
|
|
317
|
+
onboarding = "onboarded" if onboard else current.onboarding
|
|
318
|
+
if (
|
|
319
|
+
current.recorded
|
|
320
|
+
and current.mode is target_mode
|
|
321
|
+
and current.onboarding == onboarding
|
|
322
|
+
):
|
|
323
|
+
continue
|
|
324
|
+
db.execute(
|
|
325
|
+
"INSERT INTO repository_governance (account_id, repository_id, onboarding, mode, "
|
|
326
|
+
"discovered_at, onboarded_at, onboarded_by, mode_changed_at, mode_changed_by, "
|
|
327
|
+
"updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
|
328
|
+
"ON CONFLICT (account_id, repository_id) DO UPDATE SET "
|
|
329
|
+
"onboarding = excluded.onboarding, mode = excluded.mode, "
|
|
330
|
+
"onboarded_at = COALESCE(excluded.onboarded_at, "
|
|
331
|
+
"repository_governance.onboarded_at), "
|
|
332
|
+
"onboarded_by = COALESCE(excluded.onboarded_by, "
|
|
333
|
+
"repository_governance.onboarded_by), "
|
|
334
|
+
"mode_changed_at = COALESCE(excluded.mode_changed_at, "
|
|
335
|
+
"repository_governance.mode_changed_at), mode_changed_by = "
|
|
336
|
+
"COALESCE(excluded.mode_changed_by, repository_governance.mode_changed_by), "
|
|
337
|
+
"updated_at = excluded.updated_at",
|
|
338
|
+
(
|
|
339
|
+
account_id,
|
|
340
|
+
repository_id,
|
|
341
|
+
onboarding,
|
|
342
|
+
target_mode.value,
|
|
343
|
+
ts(now),
|
|
344
|
+
ts(now) if onboard and current.onboarding != "onboarded" else None,
|
|
345
|
+
actor.login if onboard and current.onboarding != "onboarded" else None,
|
|
346
|
+
ts(now) if current.mode is not target_mode else None,
|
|
347
|
+
actor.login if current.mode is not target_mode else None,
|
|
348
|
+
ts(now),
|
|
349
|
+
),
|
|
350
|
+
)
|
|
351
|
+
if current.mode is not target_mode:
|
|
352
|
+
changed_mode.append(repository_id)
|
|
353
|
+
if onboard and current.onboarding != "onboarded":
|
|
354
|
+
newly_onboarded.append(repository_id)
|
|
355
|
+
events: list[AuditEvent] = []
|
|
356
|
+
if changed_mode:
|
|
357
|
+
invalidate_repositories(db, account_id, changed_mode, now)
|
|
358
|
+
events.append(
|
|
359
|
+
self._store.insert_audit_event(
|
|
360
|
+
db,
|
|
361
|
+
self._audit.build(
|
|
362
|
+
AuditEventType.REPOSITORY_MODE_CHANGED,
|
|
363
|
+
actor=actor,
|
|
364
|
+
account_id=account_id,
|
|
365
|
+
mode=(mode or RepositoryMode.ENFORCE).value,
|
|
366
|
+
repositories=len(changed_mode),
|
|
367
|
+
repository_ids=json.dumps(changed_mode[:50]),
|
|
368
|
+
reason=reason,
|
|
369
|
+
),
|
|
370
|
+
)
|
|
371
|
+
)
|
|
372
|
+
if newly_onboarded:
|
|
373
|
+
events.append(
|
|
374
|
+
self._store.insert_audit_event(
|
|
375
|
+
db,
|
|
376
|
+
self._audit.build(
|
|
377
|
+
AuditEventType.REPOSITORY_ONBOARDED,
|
|
378
|
+
actor=actor,
|
|
379
|
+
account_id=account_id,
|
|
380
|
+
repositories=len(newly_onboarded),
|
|
381
|
+
repository_ids=json.dumps(newly_onboarded[:50]),
|
|
382
|
+
mode=mode.value if mode else None,
|
|
383
|
+
),
|
|
384
|
+
)
|
|
385
|
+
)
|
|
386
|
+
return sorted(set(changed_mode) | set(newly_onboarded)), events
|