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