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,448 @@
|
|
|
1
|
+
"""Repository groups: named sets of repositories that policies and exceptions can target.
|
|
2
|
+
|
|
3
|
+
A repository may belong to any number of groups (``Production`` and
|
|
4
|
+
``Backend``). Where several groups set a *default* for the same rule the most
|
|
5
|
+
restrictive default applies; *mandatory* requirements always combine to the
|
|
6
|
+
strongest (see :mod:`commitguard.policies.governance`).
|
|
7
|
+
|
|
8
|
+
Groups are archived, never deleted: policy versions, exceptions and audit
|
|
9
|
+
events keep referring to them. Membership changes need
|
|
10
|
+
``repositories:manage``, name only repositories of the same organization that
|
|
11
|
+
the caller can see, are audited, and invalidate the effective policy of exactly
|
|
12
|
+
the repositories added or removed.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import sqlite3
|
|
16
|
+
from collections.abc import Callable, Collection, Sequence
|
|
17
|
+
from datetime import UTC, datetime
|
|
18
|
+
|
|
19
|
+
from pydantic import BaseModel, ConfigDict
|
|
20
|
+
|
|
21
|
+
from commitguard.audit.models import Actor, AuditEvent, AuditEventType
|
|
22
|
+
from commitguard.controlplane.access import Permission, Principal
|
|
23
|
+
from commitguard.controlplane.errors import (
|
|
24
|
+
ConfirmationRequiredError,
|
|
25
|
+
ConflictError,
|
|
26
|
+
InputValidationError,
|
|
27
|
+
NotFoundError,
|
|
28
|
+
)
|
|
29
|
+
from commitguard.github.storage import SqliteStateStore
|
|
30
|
+
from commitguard.governance.cache import group_member_ids, invalidate_repositories
|
|
31
|
+
from commitguard.governance.common import (
|
|
32
|
+
MAX_DESCRIPTION_CHARS,
|
|
33
|
+
MAX_NAME_CHARS,
|
|
34
|
+
account_repositories,
|
|
35
|
+
dt,
|
|
36
|
+
is_hex_id,
|
|
37
|
+
new_id,
|
|
38
|
+
req_dt,
|
|
39
|
+
require,
|
|
40
|
+
require_visible_repositories,
|
|
41
|
+
text,
|
|
42
|
+
ts,
|
|
43
|
+
visible_repository_ids,
|
|
44
|
+
)
|
|
45
|
+
from commitguard.services.audit import AuditService
|
|
46
|
+
|
|
47
|
+
MAX_GROUPS_PER_ORGANIZATION = 500
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class RepositoryGroupView(BaseModel):
|
|
51
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
52
|
+
|
|
53
|
+
id: str
|
|
54
|
+
organization_id: int
|
|
55
|
+
name: str
|
|
56
|
+
description: str | None
|
|
57
|
+
repository_count: int
|
|
58
|
+
policy_version: int
|
|
59
|
+
active_exceptions: int
|
|
60
|
+
created_at: datetime
|
|
61
|
+
created_by: str | None
|
|
62
|
+
updated_at: datetime
|
|
63
|
+
archived_at: datetime | None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class GroupMemberView(BaseModel):
|
|
67
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
68
|
+
|
|
69
|
+
repository_id: int
|
|
70
|
+
full_name: str
|
|
71
|
+
added_at: datetime
|
|
72
|
+
added_by: str | None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class RepositoryGroupDetail(BaseModel):
|
|
76
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
77
|
+
|
|
78
|
+
group: RepositoryGroupView
|
|
79
|
+
repositories: tuple[GroupMemberView, ...]
|
|
80
|
+
hidden_repositories: int # members GitHub did not report to this session
|
|
81
|
+
can_manage: bool
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
_GROUP_SELECT = (
|
|
85
|
+
"SELECT g.*, (SELECT COUNT(*) FROM repository_group_members m WHERE m.group_id = g.group_id) "
|
|
86
|
+
"AS repository_count, COALESCE((SELECT MAX(version) FROM scoped_policy_versions p WHERE "
|
|
87
|
+
"p.account_id = g.account_id AND p.target_type = 'group' AND p.target_id = g.group_id), 0) "
|
|
88
|
+
"AS policy_version, (SELECT COUNT(*) FROM policy_exceptions e WHERE e.account_id = "
|
|
89
|
+
"g.account_id AND e.scope_type = 'group' AND e.scope_id = g.group_id AND e.status = 'active') "
|
|
90
|
+
"AS active_exceptions FROM repository_groups g"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _name_key(name: str) -> str:
|
|
95
|
+
return " ".join(name.casefold().split())
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class RepositoryGroupService:
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
store: SqliteStateStore,
|
|
102
|
+
audit: AuditService,
|
|
103
|
+
*,
|
|
104
|
+
now: Callable[[], datetime] = lambda: datetime.now(UTC),
|
|
105
|
+
) -> None:
|
|
106
|
+
self._store = store
|
|
107
|
+
self._audit = audit
|
|
108
|
+
self._now = now
|
|
109
|
+
|
|
110
|
+
@staticmethod
|
|
111
|
+
def _view(r: sqlite3.Row) -> RepositoryGroupView:
|
|
112
|
+
return RepositoryGroupView(
|
|
113
|
+
id=r["group_id"],
|
|
114
|
+
organization_id=int(r["account_id"]),
|
|
115
|
+
name=r["name"],
|
|
116
|
+
description=r["description"],
|
|
117
|
+
repository_count=int(r["repository_count"]),
|
|
118
|
+
policy_version=int(r["policy_version"]),
|
|
119
|
+
active_exceptions=int(r["active_exceptions"]),
|
|
120
|
+
created_at=req_dt(r["created_at"]),
|
|
121
|
+
created_by=r["created_by"],
|
|
122
|
+
updated_at=req_dt(r["updated_at"]),
|
|
123
|
+
archived_at=dt(r["archived_at"]),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def list_groups(
|
|
127
|
+
self, principal: Principal, account_id: int, *, include_archived: bool = False
|
|
128
|
+
) -> list[RepositoryGroupView]:
|
|
129
|
+
require(principal, Permission.REPOSITORIES_READ, account_id)
|
|
130
|
+
sql = f"{_GROUP_SELECT} WHERE g.account_id = ?"
|
|
131
|
+
if not include_archived:
|
|
132
|
+
sql += " AND g.archived_at IS NULL"
|
|
133
|
+
rows = self._store.query(sql + " ORDER BY g.name_key LIMIT 1000", (account_id,))
|
|
134
|
+
return [self._view(row) for row in rows]
|
|
135
|
+
|
|
136
|
+
def _group_row(self, group_id: str) -> sqlite3.Row:
|
|
137
|
+
if not is_hex_id(group_id):
|
|
138
|
+
raise NotFoundError()
|
|
139
|
+
rows = self._store.query(f"{_GROUP_SELECT} WHERE g.group_id = ?", (group_id,))
|
|
140
|
+
if not rows:
|
|
141
|
+
raise NotFoundError()
|
|
142
|
+
return rows[0]
|
|
143
|
+
|
|
144
|
+
def account_of(self, principal: Principal, group_id: str, permission: Permission) -> int:
|
|
145
|
+
"""The group's organization after checking the caller may ``permission`` there."""
|
|
146
|
+
row = self._group_row(group_id)
|
|
147
|
+
account_id = int(row["account_id"])
|
|
148
|
+
require(principal, permission, account_id)
|
|
149
|
+
return account_id
|
|
150
|
+
|
|
151
|
+
def get(self, principal: Principal, group_id: str) -> RepositoryGroupDetail:
|
|
152
|
+
row = self._group_row(group_id)
|
|
153
|
+
account_id = int(row["account_id"])
|
|
154
|
+
require(principal, Permission.REPOSITORIES_READ, account_id)
|
|
155
|
+
visible = visible_repository_ids(self._store, principal, account_id)
|
|
156
|
+
known = account_repositories(self._store, account_id)
|
|
157
|
+
members = self._store.query(
|
|
158
|
+
"SELECT repository_id, added_at, added_by FROM repository_group_members "
|
|
159
|
+
"WHERE group_id = ? ORDER BY added_at",
|
|
160
|
+
(group_id,),
|
|
161
|
+
)
|
|
162
|
+
shown = []
|
|
163
|
+
hidden = 0
|
|
164
|
+
for member in members:
|
|
165
|
+
repository_id = int(member["repository_id"])
|
|
166
|
+
repository = known.get(repository_id)
|
|
167
|
+
if repository is None or repository_id not in visible:
|
|
168
|
+
hidden += 1
|
|
169
|
+
continue
|
|
170
|
+
shown.append(
|
|
171
|
+
GroupMemberView(
|
|
172
|
+
repository_id=repository_id,
|
|
173
|
+
full_name=repository.full_name,
|
|
174
|
+
added_at=req_dt(member["added_at"]),
|
|
175
|
+
added_by=member["added_by"],
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
shown.sort(key=lambda m: m.full_name.casefold())
|
|
179
|
+
return RepositoryGroupDetail(
|
|
180
|
+
group=self._view(row),
|
|
181
|
+
repositories=tuple(shown),
|
|
182
|
+
hidden_repositories=hidden,
|
|
183
|
+
can_manage=principal.can(Permission.REPOSITORIES_MANAGE, account_id),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def create(
|
|
187
|
+
self, principal: Principal, account_id: int, *, name: object, description: object
|
|
188
|
+
) -> RepositoryGroupView:
|
|
189
|
+
require(principal, Permission.REPOSITORIES_MANAGE, account_id)
|
|
190
|
+
clean_name = text(name, "name", limit=MAX_NAME_CHARS, required=True)
|
|
191
|
+
assert clean_name is not None # noqa: S101 - required=True
|
|
192
|
+
clean_description = text(description, "description", limit=MAX_DESCRIPTION_CHARS)
|
|
193
|
+
now = self._now()
|
|
194
|
+
group_id = new_id()
|
|
195
|
+
actor = Actor.user(principal.user_id, principal.login)
|
|
196
|
+
with self._store.transaction() as db:
|
|
197
|
+
count = db.execute(
|
|
198
|
+
"SELECT COUNT(*) AS n FROM repository_groups WHERE account_id = ? "
|
|
199
|
+
"AND archived_at IS NULL",
|
|
200
|
+
(account_id,),
|
|
201
|
+
).fetchone()["n"]
|
|
202
|
+
if int(count) >= MAX_GROUPS_PER_ORGANIZATION:
|
|
203
|
+
raise ConflictError(
|
|
204
|
+
f"An organization can have at most {MAX_GROUPS_PER_ORGANIZATION} groups."
|
|
205
|
+
)
|
|
206
|
+
if db.execute(
|
|
207
|
+
"SELECT 1 FROM repository_groups WHERE account_id = ? AND name_key = ? "
|
|
208
|
+
"AND archived_at IS NULL",
|
|
209
|
+
(account_id, _name_key(clean_name)),
|
|
210
|
+
).fetchone():
|
|
211
|
+
raise ConflictError("A group with this name already exists.")
|
|
212
|
+
db.execute(
|
|
213
|
+
"INSERT INTO repository_groups (group_id, account_id, name, name_key, "
|
|
214
|
+
"description, created_at, created_by, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
215
|
+
(
|
|
216
|
+
group_id,
|
|
217
|
+
account_id,
|
|
218
|
+
clean_name,
|
|
219
|
+
_name_key(clean_name),
|
|
220
|
+
clean_description,
|
|
221
|
+
ts(now),
|
|
222
|
+
principal.login,
|
|
223
|
+
ts(now),
|
|
224
|
+
),
|
|
225
|
+
)
|
|
226
|
+
stored = self._store.insert_audit_event(
|
|
227
|
+
db,
|
|
228
|
+
self._audit.build(
|
|
229
|
+
AuditEventType.REPOSITORY_GROUP_CREATED,
|
|
230
|
+
actor=actor,
|
|
231
|
+
account_id=account_id,
|
|
232
|
+
group=group_id,
|
|
233
|
+
name=clean_name,
|
|
234
|
+
),
|
|
235
|
+
)
|
|
236
|
+
self._audit.log_stored(stored)
|
|
237
|
+
return self._view(self._group_row(group_id))
|
|
238
|
+
|
|
239
|
+
def update(
|
|
240
|
+
self, principal: Principal, group_id: str, *, name: object, description: object
|
|
241
|
+
) -> RepositoryGroupView:
|
|
242
|
+
row = self._group_row(group_id)
|
|
243
|
+
account_id = int(row["account_id"])
|
|
244
|
+
require(principal, Permission.REPOSITORIES_MANAGE, account_id)
|
|
245
|
+
if row["archived_at"] is not None:
|
|
246
|
+
raise ConflictError("An archived group cannot be changed.")
|
|
247
|
+
new_name = text(name, "name", limit=MAX_NAME_CHARS) if name is not None else row["name"]
|
|
248
|
+
new_description = (
|
|
249
|
+
text(description, "description", limit=MAX_DESCRIPTION_CHARS)
|
|
250
|
+
if description is not None
|
|
251
|
+
else row["description"]
|
|
252
|
+
)
|
|
253
|
+
if new_name == row["name"] and new_description == row["description"]:
|
|
254
|
+
raise InputValidationError("Nothing to change.")
|
|
255
|
+
now = self._now()
|
|
256
|
+
with self._store.transaction() as db:
|
|
257
|
+
if (
|
|
258
|
+
new_name != row["name"]
|
|
259
|
+
and db.execute(
|
|
260
|
+
"SELECT 1 FROM repository_groups WHERE account_id = ? AND name_key = ? "
|
|
261
|
+
"AND archived_at IS NULL AND group_id != ?",
|
|
262
|
+
(account_id, _name_key(str(new_name)), group_id),
|
|
263
|
+
).fetchone()
|
|
264
|
+
):
|
|
265
|
+
raise ConflictError("A group with this name already exists.")
|
|
266
|
+
db.execute(
|
|
267
|
+
"UPDATE repository_groups SET name = ?, name_key = ?, description = ?, "
|
|
268
|
+
"updated_at = ? WHERE group_id = ?",
|
|
269
|
+
(new_name, _name_key(str(new_name)), new_description, ts(now), group_id),
|
|
270
|
+
)
|
|
271
|
+
stored = self._store.insert_audit_event(
|
|
272
|
+
db,
|
|
273
|
+
self._audit.build(
|
|
274
|
+
AuditEventType.REPOSITORY_GROUP_UPDATED,
|
|
275
|
+
actor=Actor.user(principal.user_id, principal.login),
|
|
276
|
+
account_id=account_id,
|
|
277
|
+
group=group_id,
|
|
278
|
+
name=str(new_name),
|
|
279
|
+
previous_name=str(row["name"]),
|
|
280
|
+
),
|
|
281
|
+
)
|
|
282
|
+
self._audit.log_stored(stored)
|
|
283
|
+
return self._view(self._group_row(group_id))
|
|
284
|
+
|
|
285
|
+
def archive(self, principal: Principal, group_id: str, *, confirm: object) -> None:
|
|
286
|
+
row = self._group_row(group_id)
|
|
287
|
+
account_id = int(row["account_id"])
|
|
288
|
+
require(principal, Permission.REPOSITORIES_MANAGE, account_id)
|
|
289
|
+
if row["archived_at"] is not None:
|
|
290
|
+
raise ConflictError("The group is already archived.")
|
|
291
|
+
affects_policy = int(row["policy_version"]) > 0 or int(row["active_exceptions"]) > 0
|
|
292
|
+
if affects_policy and confirm is not True:
|
|
293
|
+
raise ConfirmationRequiredError(
|
|
294
|
+
"This group has a policy or active exceptions; archiving it changes the "
|
|
295
|
+
"effective policy of its repositories and must be confirmed."
|
|
296
|
+
)
|
|
297
|
+
now = self._now()
|
|
298
|
+
with self._store.transaction() as db:
|
|
299
|
+
members = group_member_ids(db, account_id, group_id)
|
|
300
|
+
db.execute(
|
|
301
|
+
"UPDATE repository_groups SET archived_at = ?, archived_by = ?, updated_at = ? "
|
|
302
|
+
"WHERE group_id = ? AND archived_at IS NULL",
|
|
303
|
+
(ts(now), principal.login, ts(now), group_id),
|
|
304
|
+
)
|
|
305
|
+
# Exceptions scoped to an archived group end with it (history is kept).
|
|
306
|
+
db.execute(
|
|
307
|
+
"UPDATE policy_exceptions SET status = CASE status WHEN 'requested' THEN "
|
|
308
|
+
"'cancelled' ELSE 'revoked' END, revoked_at = ?, revoked_by_login = ?, "
|
|
309
|
+
"revoke_reason = 'repository group archived', updated_at = ? WHERE account_id = ? "
|
|
310
|
+
"AND scope_type = 'group' AND scope_id = ? AND status IN ('requested', 'active')",
|
|
311
|
+
(ts(now), principal.login, ts(now), account_id, group_id),
|
|
312
|
+
)
|
|
313
|
+
invalidate_repositories(db, account_id, members, now)
|
|
314
|
+
stored = self._store.insert_audit_event(
|
|
315
|
+
db,
|
|
316
|
+
self._audit.build(
|
|
317
|
+
AuditEventType.REPOSITORY_GROUP_ARCHIVED,
|
|
318
|
+
actor=Actor.user(principal.user_id, principal.login),
|
|
319
|
+
account_id=account_id,
|
|
320
|
+
group=group_id,
|
|
321
|
+
name=str(row["name"]),
|
|
322
|
+
repositories=len(members),
|
|
323
|
+
),
|
|
324
|
+
)
|
|
325
|
+
self._audit.log_stored(stored)
|
|
326
|
+
|
|
327
|
+
def add_members(
|
|
328
|
+
self, principal: Principal, group_id: str, repository_ids: Sequence[int]
|
|
329
|
+
) -> RepositoryGroupDetail:
|
|
330
|
+
row = self._group_row(group_id)
|
|
331
|
+
account_id = int(row["account_id"])
|
|
332
|
+
require(principal, Permission.REPOSITORIES_MANAGE, account_id)
|
|
333
|
+
ids = require_visible_repositories(self._store, principal, account_id, repository_ids)
|
|
334
|
+
with self._store.transaction() as db:
|
|
335
|
+
_, added = self.add_members_in(
|
|
336
|
+
db, account_id, group_id, ids, actor=Actor.user(principal.user_id, principal.login)
|
|
337
|
+
)
|
|
338
|
+
if added is not None:
|
|
339
|
+
self._audit.log_stored(added)
|
|
340
|
+
return self.get(principal, group_id)
|
|
341
|
+
|
|
342
|
+
def add_members_in(
|
|
343
|
+
self,
|
|
344
|
+
db: sqlite3.Connection,
|
|
345
|
+
account_id: int,
|
|
346
|
+
group_id: str,
|
|
347
|
+
repository_ids: Sequence[int],
|
|
348
|
+
*,
|
|
349
|
+
actor: Actor,
|
|
350
|
+
known: Collection[int] | None = None,
|
|
351
|
+
) -> tuple[list[int], AuditEvent | None]:
|
|
352
|
+
"""Add members inside a caller's transaction (also used by bulk operations).
|
|
353
|
+
|
|
354
|
+
``known`` - the organization's repository IDs when the caller already has them.
|
|
355
|
+
|
|
356
|
+
Returns the repositories added and the stored audit event (None when every
|
|
357
|
+
repository was already a member).
|
|
358
|
+
"""
|
|
359
|
+
group = db.execute(
|
|
360
|
+
"SELECT archived_at FROM repository_groups WHERE group_id = ? AND account_id = ?",
|
|
361
|
+
(group_id, account_id),
|
|
362
|
+
).fetchone()
|
|
363
|
+
if group is None:
|
|
364
|
+
raise NotFoundError()
|
|
365
|
+
if group["archived_at"] is not None:
|
|
366
|
+
raise ConflictError("An archived group cannot be changed.")
|
|
367
|
+
now = self._now()
|
|
368
|
+
if known is None:
|
|
369
|
+
known = account_repositories(db, account_id).keys()
|
|
370
|
+
added = []
|
|
371
|
+
for repository_id in repository_ids:
|
|
372
|
+
if repository_id not in known:
|
|
373
|
+
raise NotFoundError("A selected repository was not found in this organization.")
|
|
374
|
+
cursor = db.execute(
|
|
375
|
+
"INSERT OR IGNORE INTO repository_group_members (group_id, account_id, "
|
|
376
|
+
"repository_id, added_at, added_by) VALUES (?, ?, ?, ?, ?)",
|
|
377
|
+
(group_id, account_id, repository_id, ts(now), actor.login),
|
|
378
|
+
)
|
|
379
|
+
if cursor.rowcount:
|
|
380
|
+
added.append(repository_id)
|
|
381
|
+
if not added:
|
|
382
|
+
return [], None
|
|
383
|
+
db.execute(
|
|
384
|
+
"UPDATE repository_groups SET updated_at = ? WHERE group_id = ?", (ts(now), group_id)
|
|
385
|
+
)
|
|
386
|
+
invalidate_repositories(db, account_id, added, now)
|
|
387
|
+
return added, self._store.insert_audit_event(
|
|
388
|
+
db,
|
|
389
|
+
self._audit.build(
|
|
390
|
+
AuditEventType.REPOSITORY_GROUP_MEMBERS_ADDED,
|
|
391
|
+
actor=actor,
|
|
392
|
+
account_id=account_id,
|
|
393
|
+
group=group_id,
|
|
394
|
+
repositories=len(added),
|
|
395
|
+
repository_ids=",".join(str(i) for i in added[:50]),
|
|
396
|
+
),
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
def remove_members(
|
|
400
|
+
self, principal: Principal, group_id: str, repository_ids: Sequence[int]
|
|
401
|
+
) -> RepositoryGroupDetail:
|
|
402
|
+
row = self._group_row(group_id)
|
|
403
|
+
account_id = int(row["account_id"])
|
|
404
|
+
require(principal, Permission.REPOSITORIES_MANAGE, account_id)
|
|
405
|
+
ids = require_visible_repositories(self._store, principal, account_id, repository_ids)
|
|
406
|
+
with self._store.transaction() as db:
|
|
407
|
+
_, stored = self.remove_members_in(
|
|
408
|
+
db, account_id, group_id, ids, actor=Actor.user(principal.user_id, principal.login)
|
|
409
|
+
)
|
|
410
|
+
if stored is not None:
|
|
411
|
+
self._audit.log_stored(stored)
|
|
412
|
+
return self.get(principal, group_id)
|
|
413
|
+
|
|
414
|
+
def remove_members_in(
|
|
415
|
+
self,
|
|
416
|
+
db: sqlite3.Connection,
|
|
417
|
+
account_id: int,
|
|
418
|
+
group_id: str,
|
|
419
|
+
repository_ids: Sequence[int],
|
|
420
|
+
*,
|
|
421
|
+
actor: Actor,
|
|
422
|
+
known: Collection[int] | None = None, # accepted for symmetry: removal needs no lookup
|
|
423
|
+
) -> tuple[list[int], AuditEvent | None]:
|
|
424
|
+
del known
|
|
425
|
+
now = self._now()
|
|
426
|
+
removed = []
|
|
427
|
+
for repository_id in repository_ids:
|
|
428
|
+
cursor = db.execute(
|
|
429
|
+
"DELETE FROM repository_group_members WHERE group_id = ? AND account_id = ? "
|
|
430
|
+
"AND repository_id = ?",
|
|
431
|
+
(group_id, account_id, repository_id),
|
|
432
|
+
)
|
|
433
|
+
if cursor.rowcount:
|
|
434
|
+
removed.append(repository_id)
|
|
435
|
+
if not removed:
|
|
436
|
+
return [], None
|
|
437
|
+
invalidate_repositories(db, account_id, removed, now)
|
|
438
|
+
return removed, self._store.insert_audit_event(
|
|
439
|
+
db,
|
|
440
|
+
self._audit.build(
|
|
441
|
+
AuditEventType.REPOSITORY_GROUP_MEMBERS_REMOVED,
|
|
442
|
+
actor=actor,
|
|
443
|
+
account_id=account_id,
|
|
444
|
+
group=group_id,
|
|
445
|
+
repositories=len(removed),
|
|
446
|
+
repository_ids=",".join(str(i) for i in removed[:50]),
|
|
447
|
+
),
|
|
448
|
+
)
|