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,1204 @@
|
|
|
1
|
+
"""Versioned policy for organizations, repository groups and repositories.
|
|
2
|
+
|
|
3
|
+
Policy hierarchy (see :mod:`commitguard.policies.governance` for the exact
|
|
4
|
+
per-rule precedence)::
|
|
5
|
+
|
|
6
|
+
service policy COMMITGUARD_APP_MANDATORY_POLICY_FILE (operator), optional
|
|
7
|
+
+ organization policy versioned in the dashboard (this module)
|
|
8
|
+
+ repository group policy versioned, per group (this module)
|
|
9
|
+
+ repository policy versioned, per repository in the dashboard (this module)
|
|
10
|
+
+ repository configuration .commitguard.yaml at the trusted revision (base commit)
|
|
11
|
+
+ approved exceptions, monitor mode
|
|
12
|
+
= effective policy resolved per repository, recorded with every scan
|
|
13
|
+
|
|
14
|
+
A policy version is a set of entries, one per rule, each with a **strength**:
|
|
15
|
+
|
|
16
|
+
* ``mandatory`` (``warn`` or ``block``): a *floor*. It is applied with the same
|
|
17
|
+
code as the service policy (:func:`commitguard.policies.mandatory.apply_mandatory_policies`),
|
|
18
|
+
so a repository configuration - or a narrower dashboard policy - that disables
|
|
19
|
+
or lowers the rule is still evaluated at the floor, and the attempt is shown
|
|
20
|
+
as a policy conflict. Removing or lowering a floor is a *weakening* change: it
|
|
21
|
+
needs an explicit confirmation and a recent sign-in, and is audited like any change.
|
|
22
|
+
* ``default`` (``allow``, ``warn`` or ``block``): a baseline that narrower
|
|
23
|
+
levels and the repository configuration may replace in either direction.
|
|
24
|
+
|
|
25
|
+
The stored document is canonical JSON: a mandatory entry is its action
|
|
26
|
+
(``{"ai_coauthor": "block"}``, the Phase 6 format, so existing versions keep
|
|
27
|
+
their fingerprints); a default entry is ``{"action": "warn", "enforcement": "default"}``.
|
|
28
|
+
|
|
29
|
+
Versions are immutable rows (database triggers refuse ``UPDATE`` and
|
|
30
|
+
``DELETE``). The organization's versions live in ``organization_policy_versions``,
|
|
31
|
+
group and repository versions in ``scoped_policy_versions``. Every scan stores the
|
|
32
|
+
policy versions and the fingerprint of the effective policy set it was evaluated
|
|
33
|
+
with, so a historical result keeps showing the policy that produced it.
|
|
34
|
+
|
|
35
|
+
Version lifecycle: the newest version of a target is its latest *published*
|
|
36
|
+
version; older versions are ``archived``. Proposed changes live in drafts
|
|
37
|
+
(:mod:`commitguard.governance.workflow`) until they are published. A staged
|
|
38
|
+
rollout (:mod:`commitguard.governance.rollouts`) may apply the newest version to
|
|
39
|
+
pilot repositories first; everything else keeps the previous version until the
|
|
40
|
+
rollout expands. An archived version becomes the basis of enforcement again only
|
|
41
|
+
through an explicit **rollback**, which never edits or deletes anything - it
|
|
42
|
+
publishes a *new* version whose document is the restored one and records the
|
|
43
|
+
lineage::
|
|
44
|
+
|
|
45
|
+
v13 archived Added bot restriction
|
|
46
|
+
v14 active rollback: restores v12 (replaced v13)
|
|
47
|
+
|
|
48
|
+
Rollback needs ``policies:rollback``, a reason, an explicit confirmation, the
|
|
49
|
+
version the administrator was looking at (``expected_current_version``), and -
|
|
50
|
+
when it weakens a floor - a recent sign-in. The target's stored fingerprint must
|
|
51
|
+
match its document. Version, audit event, notification and the invalidation of
|
|
52
|
+
affected repositories' effective policies are written in one transaction: a
|
|
53
|
+
failure leaves the active version unchanged.
|
|
54
|
+
|
|
55
|
+
Concurrency: an update or rollback names the version it was based on. If
|
|
56
|
+
another administrator published a newer version first, the request is rejected
|
|
57
|
+
with a conflict instead of silently overwriting their change.
|
|
58
|
+
|
|
59
|
+
Scans resolve policy from the database when they start (through the
|
|
60
|
+
governance resolver's cache, which every publication invalidates in the same
|
|
61
|
+
transaction), so a change applies to the next scan in every process and host,
|
|
62
|
+
and a scan that started before the change keeps reporting the version it was
|
|
63
|
+
evaluated with.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
import json
|
|
67
|
+
import sqlite3
|
|
68
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
69
|
+
from dataclasses import dataclass, field
|
|
70
|
+
from datetime import UTC, datetime, timedelta
|
|
71
|
+
from enum import StrEnum
|
|
72
|
+
from typing import Literal
|
|
73
|
+
|
|
74
|
+
from commitguard.audit.models import Actor, AuditEventType
|
|
75
|
+
from commitguard.config.schema import CommitGuardConfig, PolicyOverride
|
|
76
|
+
from commitguard.config.sources import MandatoryPolicy
|
|
77
|
+
from commitguard.controlplane.errors import (
|
|
78
|
+
ConfirmationRequiredError,
|
|
79
|
+
ConflictError,
|
|
80
|
+
InputValidationError,
|
|
81
|
+
NotFoundError,
|
|
82
|
+
PolicyIntegrityError,
|
|
83
|
+
ReauthenticationRequiredError,
|
|
84
|
+
)
|
|
85
|
+
from commitguard.controlplane.results import clean_text
|
|
86
|
+
from commitguard.controlplane.rules import CATALOG_BY_ID
|
|
87
|
+
from commitguard.controlplane.views import (
|
|
88
|
+
OrganizationPolicyView,
|
|
89
|
+
OrganizationRef,
|
|
90
|
+
PolicyAuthor,
|
|
91
|
+
PolicyChange,
|
|
92
|
+
PolicyDiffEntry,
|
|
93
|
+
PolicyDiffView,
|
|
94
|
+
PolicyRuleView,
|
|
95
|
+
PolicyTargetView,
|
|
96
|
+
PolicyVersionView,
|
|
97
|
+
ScopedPolicyView,
|
|
98
|
+
ScopedRuleView,
|
|
99
|
+
)
|
|
100
|
+
from commitguard.core.decision import Action
|
|
101
|
+
from commitguard.core.result import Severity
|
|
102
|
+
from commitguard.github.storage import SqliteStateStore
|
|
103
|
+
from commitguard.notifications.deduplication import domain_key
|
|
104
|
+
from commitguard.notifications.models import NotificationEvent, NotificationType
|
|
105
|
+
from commitguard.notifications.outbox import emit
|
|
106
|
+
from commitguard.observability.metrics import (
|
|
107
|
+
POLICY_ROLLBACK_FAILURES,
|
|
108
|
+
POLICY_ROLLBACKS,
|
|
109
|
+
Metrics,
|
|
110
|
+
NullMetrics,
|
|
111
|
+
)
|
|
112
|
+
from commitguard.policies.defaults import DEFAULT_POLICIES
|
|
113
|
+
from commitguard.policies.governance import Enforcement, RuleRequirement
|
|
114
|
+
from commitguard.security.hashing import sha256_hex
|
|
115
|
+
from commitguard.services.audit import AuditService
|
|
116
|
+
|
|
117
|
+
MAX_REASON_CHARS = 500
|
|
118
|
+
#: Weakening changes require a sign-in no older than this.
|
|
119
|
+
REAUTHENTICATION_WINDOW = timedelta(minutes=15)
|
|
120
|
+
FLOOR_ACTIONS = (Action.WARN, Action.BLOCK)
|
|
121
|
+
#: Largest accepted policy document (resource exhaustion protection).
|
|
122
|
+
MAX_DOCUMENT_BYTES = 16_384
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class PolicyTargetType(StrEnum):
|
|
126
|
+
ORGANIZATION = "organization"
|
|
127
|
+
GROUP = "group"
|
|
128
|
+
REPOSITORY = "repository"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass(frozen=True, slots=True)
|
|
132
|
+
class PolicyTarget:
|
|
133
|
+
type: PolicyTargetType
|
|
134
|
+
id: str = "" # "" for the organization; a group ID or a repository ID
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def scoped(self) -> bool:
|
|
138
|
+
return self.type is not PolicyTargetType.ORGANIZATION
|
|
139
|
+
|
|
140
|
+
@classmethod
|
|
141
|
+
def group(cls, group_id: str) -> "PolicyTarget":
|
|
142
|
+
return cls(PolicyTargetType.GROUP, group_id)
|
|
143
|
+
|
|
144
|
+
@classmethod
|
|
145
|
+
def repository(cls, repository_id: int) -> "PolicyTarget":
|
|
146
|
+
return cls(PolicyTargetType.REPOSITORY, str(int(repository_id)))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
ORGANIZATION_TARGET = PolicyTarget(PolicyTargetType.ORGANIZATION)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass(frozen=True, slots=True)
|
|
153
|
+
class PolicyVersion:
|
|
154
|
+
account_id: int
|
|
155
|
+
version: int # 0: no policy has been saved for this target
|
|
156
|
+
floors: Mapping[str, Action] # mandatory entries
|
|
157
|
+
fingerprint: str | None
|
|
158
|
+
created_at: datetime | None
|
|
159
|
+
created_by_id: int | None
|
|
160
|
+
created_by_login: str | None
|
|
161
|
+
reason: str | None
|
|
162
|
+
kind: str = "change" # change | rollback
|
|
163
|
+
rollback_of: int | None = None # the version that was active when rolling back
|
|
164
|
+
restored_version: int | None = None # the version whose document was restored
|
|
165
|
+
document: str | None = None
|
|
166
|
+
defaults: Mapping[str, Action] = field(default_factory=dict) # default entries
|
|
167
|
+
target: PolicyTarget = ORGANIZATION_TARGET
|
|
168
|
+
draft_id: str | None = None
|
|
169
|
+
emergency: bool = False
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def requirements(self) -> dict[str, RuleRequirement]:
|
|
173
|
+
entries = {
|
|
174
|
+
rule: RuleRequirement(action=action, enforcement=Enforcement.DEFAULT)
|
|
175
|
+
for rule, action in self.defaults.items()
|
|
176
|
+
}
|
|
177
|
+
entries.update(
|
|
178
|
+
{rule: RuleRequirement(action=action) for rule, action in self.floors.items()}
|
|
179
|
+
)
|
|
180
|
+
return entries
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass(frozen=True, slots=True)
|
|
184
|
+
class PublishedPolicy:
|
|
185
|
+
"""Passed to publish hooks, inside the publishing transaction."""
|
|
186
|
+
|
|
187
|
+
account_id: int
|
|
188
|
+
target: PolicyTarget
|
|
189
|
+
version: int
|
|
190
|
+
previous_version: int
|
|
191
|
+
kind: str
|
|
192
|
+
now: datetime
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
type PublishHook = Callable[[sqlite3.Connection, PublishedPolicy], None]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def canonical_document(floors: Mapping[str, Action], defaults: Mapping[str, Action]) -> str:
|
|
199
|
+
overlap = sorted(set(floors) & set(defaults))
|
|
200
|
+
if overlap:
|
|
201
|
+
raise InputValidationError(
|
|
202
|
+
f"a rule is either mandatory or a default, not both: {', '.join(overlap)}",
|
|
203
|
+
field="defaults",
|
|
204
|
+
)
|
|
205
|
+
document: dict[str, object] = {k: floors[k].value for k in floors}
|
|
206
|
+
document.update({k: {"action": defaults[k].value, "enforcement": "default"} for k in defaults})
|
|
207
|
+
return json.dumps({k: document[k] for k in sorted(document)}, separators=(",", ":"))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# Phase 7 name, kept for callers that only deal with floors.
|
|
211
|
+
def _canonical(floors: Mapping[str, Action]) -> str:
|
|
212
|
+
return canonical_document(floors, {})
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def parse_document(document: str) -> tuple[dict[str, Action], dict[str, Action]]:
|
|
216
|
+
"""Split a stored document into (mandatory floors, defaults). Raises ``ValueError``."""
|
|
217
|
+
raw = json.loads(document)
|
|
218
|
+
if not isinstance(raw, dict):
|
|
219
|
+
raise ValueError("policy document must be an object")
|
|
220
|
+
floors: dict[str, Action] = {}
|
|
221
|
+
defaults: dict[str, Action] = {}
|
|
222
|
+
for key, value in raw.items():
|
|
223
|
+
if key not in DEFAULT_POLICIES:
|
|
224
|
+
raise ValueError("unknown policy ID")
|
|
225
|
+
if isinstance(value, str):
|
|
226
|
+
action = Action(value)
|
|
227
|
+
if action not in FLOOR_ACTIONS:
|
|
228
|
+
raise ValueError("a mandatory entry must be warn or block")
|
|
229
|
+
floors[key] = action
|
|
230
|
+
elif isinstance(value, dict) and set(value) == {"action", "enforcement"}:
|
|
231
|
+
if value["enforcement"] != "default" or not isinstance(value["action"], str):
|
|
232
|
+
raise ValueError("invalid default entry")
|
|
233
|
+
defaults[key] = Action(value["action"])
|
|
234
|
+
else:
|
|
235
|
+
raise ValueError("invalid policy entry")
|
|
236
|
+
return floors, defaults
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def validate_floors(raw: object) -> dict[str, Action]:
|
|
240
|
+
if not isinstance(raw, Mapping):
|
|
241
|
+
raise InputValidationError("floors must be an object of policy IDs", field="floors")
|
|
242
|
+
floors: dict[str, Action] = {}
|
|
243
|
+
for key, value in raw.items():
|
|
244
|
+
if not isinstance(key, str) or key not in DEFAULT_POLICIES:
|
|
245
|
+
raise InputValidationError("unknown policy ID in floors", field="floors")
|
|
246
|
+
if value is None:
|
|
247
|
+
continue # no floor: the repository decides
|
|
248
|
+
if not isinstance(value, str) or value not in (a.value for a in FLOOR_ACTIONS):
|
|
249
|
+
raise InputValidationError(
|
|
250
|
+
f"the floor for {key} must be warn, block or null", field=f"floors.{key}"
|
|
251
|
+
)
|
|
252
|
+
floors[key] = Action(value)
|
|
253
|
+
return floors
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def validate_defaults(raw: object) -> dict[str, Action]:
|
|
257
|
+
if not isinstance(raw, Mapping):
|
|
258
|
+
raise InputValidationError("defaults must be an object of policy IDs", field="defaults")
|
|
259
|
+
defaults: dict[str, Action] = {}
|
|
260
|
+
for key, value in raw.items():
|
|
261
|
+
if not isinstance(key, str) or key not in DEFAULT_POLICIES:
|
|
262
|
+
raise InputValidationError("unknown policy ID in defaults", field="defaults")
|
|
263
|
+
if value is None:
|
|
264
|
+
continue
|
|
265
|
+
if not isinstance(value, str) or value not in (a.value for a in Action):
|
|
266
|
+
raise InputValidationError(
|
|
267
|
+
f"the default for {key} must be allow, warn, block or null",
|
|
268
|
+
field=f"defaults.{key}",
|
|
269
|
+
)
|
|
270
|
+
defaults[key] = Action(value)
|
|
271
|
+
return defaults
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _label(change: PolicyChange) -> str:
|
|
275
|
+
suffix = " (default)" if change.enforcement == "default" else ""
|
|
276
|
+
return f"{change.policy_id}{suffix}"
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def change_summary(changes: Sequence[PolicyChange]) -> str:
|
|
280
|
+
return "; ".join(
|
|
281
|
+
f"{_label(c)}: {c.old.value if c.old else 'repository'} -> "
|
|
282
|
+
f"{c.new.value if c.new else 'repository'}"
|
|
283
|
+
for c in changes
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
_change_summary = change_summary
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def policy_changes(
|
|
291
|
+
old: Mapping[str, Action],
|
|
292
|
+
new: Mapping[str, Action],
|
|
293
|
+
old_defaults: Mapping[str, Action] | None = None,
|
|
294
|
+
new_defaults: Mapping[str, Action] | None = None,
|
|
295
|
+
) -> list[PolicyChange]:
|
|
296
|
+
changes = []
|
|
297
|
+
for policy_id in sorted(set(old) | set(new)):
|
|
298
|
+
before, after = old.get(policy_id), new.get(policy_id)
|
|
299
|
+
if before == after:
|
|
300
|
+
continue
|
|
301
|
+
weakening = before is not None and (after is None or after.rank < before.rank)
|
|
302
|
+
changes.append(
|
|
303
|
+
PolicyChange(policy_id=policy_id, old=before, new=after, weakening=weakening)
|
|
304
|
+
)
|
|
305
|
+
old_defaults = old_defaults or {}
|
|
306
|
+
new_defaults = new_defaults or {}
|
|
307
|
+
for policy_id in sorted(set(old_defaults) | set(new_defaults)):
|
|
308
|
+
before, after = old_defaults.get(policy_id), new_defaults.get(policy_id)
|
|
309
|
+
if before == after:
|
|
310
|
+
continue
|
|
311
|
+
builtin = DEFAULT_POLICIES[policy_id].action
|
|
312
|
+
# Weaker when the baseline repositories fall back to is lower than before.
|
|
313
|
+
weakening = (after or builtin).rank < (before or builtin).rank
|
|
314
|
+
changes.append(
|
|
315
|
+
PolicyChange(
|
|
316
|
+
policy_id=policy_id,
|
|
317
|
+
old=before,
|
|
318
|
+
new=after,
|
|
319
|
+
weakening=weakening,
|
|
320
|
+
enforcement="default",
|
|
321
|
+
)
|
|
322
|
+
)
|
|
323
|
+
return changes
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def policy_diff(
|
|
327
|
+
from_version: int,
|
|
328
|
+
old: Mapping[str, Action],
|
|
329
|
+
to_version: int,
|
|
330
|
+
new: Mapping[str, Action],
|
|
331
|
+
old_defaults: Mapping[str, Action] | None = None,
|
|
332
|
+
new_defaults: Mapping[str, Action] | None = None,
|
|
333
|
+
) -> PolicyDiffView:
|
|
334
|
+
"""A structured diff of two versions: added, changed, removed entries."""
|
|
335
|
+
added, changed, removed = [], [], []
|
|
336
|
+
for change in policy_changes(old, new, old_defaults, new_defaults):
|
|
337
|
+
entry = PolicyDiffEntry(
|
|
338
|
+
policy_id=change.policy_id,
|
|
339
|
+
old=change.old,
|
|
340
|
+
new=change.new,
|
|
341
|
+
weakening=change.weakening
|
|
342
|
+
if change.old is not None or change.enforcement == "default"
|
|
343
|
+
else False,
|
|
344
|
+
enforcement=change.enforcement,
|
|
345
|
+
)
|
|
346
|
+
if change.old is None:
|
|
347
|
+
added.append(entry)
|
|
348
|
+
elif change.new is None:
|
|
349
|
+
removed.append(
|
|
350
|
+
entry.model_copy(update={"weakening": True})
|
|
351
|
+
if change.enforcement == "mandatory"
|
|
352
|
+
else entry
|
|
353
|
+
)
|
|
354
|
+
else:
|
|
355
|
+
changed.append(entry)
|
|
356
|
+
return PolicyDiffView(
|
|
357
|
+
from_version=from_version,
|
|
358
|
+
to_version=to_version,
|
|
359
|
+
added=tuple(added),
|
|
360
|
+
changed=tuple(changed),
|
|
361
|
+
removed=tuple(removed),
|
|
362
|
+
weakening=any(e.weakening for e in (*added, *changed, *removed)),
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
# Static statements per storage table: no SQL is assembled from input.
|
|
367
|
+
_ORG_LATEST = (
|
|
368
|
+
"SELECT * FROM organization_policy_versions WHERE account_id = ? ORDER BY version DESC LIMIT 1"
|
|
369
|
+
)
|
|
370
|
+
_ORG_VERSION = "SELECT * FROM organization_policy_versions WHERE account_id = ? AND version = ?"
|
|
371
|
+
_ORG_PAGE = (
|
|
372
|
+
"SELECT * FROM organization_policy_versions WHERE account_id = ? "
|
|
373
|
+
"ORDER BY version DESC LIMIT ? OFFSET ?"
|
|
374
|
+
)
|
|
375
|
+
_ORG_MAX = "SELECT MAX(version) AS latest FROM organization_policy_versions WHERE account_id = ?"
|
|
376
|
+
_ORG_INSERT = (
|
|
377
|
+
"INSERT INTO organization_policy_versions (account_id, version, document, fingerprint, "
|
|
378
|
+
"created_at, created_by_id, created_by_login, reason, kind, rollback_of, restored_version, "
|
|
379
|
+
"draft_id, emergency) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
380
|
+
)
|
|
381
|
+
_SCOPED_LATEST = (
|
|
382
|
+
"SELECT * FROM scoped_policy_versions WHERE account_id = ? AND target_type = ? "
|
|
383
|
+
"AND target_id = ? ORDER BY version DESC LIMIT 1"
|
|
384
|
+
)
|
|
385
|
+
_SCOPED_VERSION = (
|
|
386
|
+
"SELECT * FROM scoped_policy_versions WHERE account_id = ? AND target_type = ? "
|
|
387
|
+
"AND target_id = ? AND version = ?"
|
|
388
|
+
)
|
|
389
|
+
_SCOPED_PAGE = (
|
|
390
|
+
"SELECT * FROM scoped_policy_versions WHERE account_id = ? AND target_type = ? "
|
|
391
|
+
"AND target_id = ? ORDER BY version DESC LIMIT ? OFFSET ?"
|
|
392
|
+
)
|
|
393
|
+
_SCOPED_MAX = (
|
|
394
|
+
"SELECT MAX(version) AS latest FROM scoped_policy_versions WHERE account_id = ? "
|
|
395
|
+
"AND target_type = ? AND target_id = ?"
|
|
396
|
+
)
|
|
397
|
+
_SCOPED_INSERT = (
|
|
398
|
+
"INSERT INTO scoped_policy_versions (account_id, target_type, target_id, version, document, "
|
|
399
|
+
"fingerprint, created_at, created_by_id, created_by_login, reason, kind, rollback_of, "
|
|
400
|
+
"restored_version, draft_id, emergency) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def target_label(db: sqlite3.Connection, account_id: int, target: PolicyTarget) -> str | None:
|
|
405
|
+
"""A display label for ``target`` within the account, or None if it is not the account's."""
|
|
406
|
+
if target.type is PolicyTargetType.ORGANIZATION:
|
|
407
|
+
return "Organization"
|
|
408
|
+
if target.type is PolicyTargetType.GROUP:
|
|
409
|
+
row = db.execute(
|
|
410
|
+
"SELECT name FROM repository_groups WHERE group_id = ? AND account_id = ? "
|
|
411
|
+
"AND archived_at IS NULL",
|
|
412
|
+
(target.id, int(account_id)),
|
|
413
|
+
).fetchone()
|
|
414
|
+
return f"Group {row['name']}" if row else None
|
|
415
|
+
if not target.id.isdigit():
|
|
416
|
+
return None
|
|
417
|
+
row = db.execute(
|
|
418
|
+
"SELECT k.owner, k.name FROM known_repositories k "
|
|
419
|
+
"JOIN installations i ON i.installation_id = k.installation_id "
|
|
420
|
+
"WHERE i.account_id = ? AND k.repository_id = ? ORDER BY k.last_seen_at DESC LIMIT 1",
|
|
421
|
+
(int(account_id), int(target.id)),
|
|
422
|
+
).fetchone()
|
|
423
|
+
return f"{row['owner']}/{row['name']}" if row else None
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
class OrganizationPolicyService:
|
|
427
|
+
"""Versions, publication and rollback of policy for any target of an account."""
|
|
428
|
+
|
|
429
|
+
def __init__(
|
|
430
|
+
self,
|
|
431
|
+
store: SqliteStateStore,
|
|
432
|
+
audit: AuditService,
|
|
433
|
+
*,
|
|
434
|
+
service_policy: MandatoryPolicy | None = None,
|
|
435
|
+
metrics: Metrics | None = None,
|
|
436
|
+
now: Callable[[], datetime] = lambda: datetime.now(UTC),
|
|
437
|
+
) -> None:
|
|
438
|
+
self._store = store
|
|
439
|
+
self._audit = audit
|
|
440
|
+
self._service_policy = service_policy
|
|
441
|
+
self._metrics: Metrics = metrics or NullMetrics()
|
|
442
|
+
self._now = now
|
|
443
|
+
self._publish_hooks: list[PublishHook] = []
|
|
444
|
+
|
|
445
|
+
@property
|
|
446
|
+
def service_policy(self) -> MandatoryPolicy | None:
|
|
447
|
+
return self._service_policy
|
|
448
|
+
|
|
449
|
+
def add_publish_hook(self, hook: PublishHook) -> None:
|
|
450
|
+
"""Run ``hook`` inside every publishing transaction (e.g. cache invalidation)."""
|
|
451
|
+
self._publish_hooks.append(hook)
|
|
452
|
+
|
|
453
|
+
# -- storage --------------------------------------------------------- #
|
|
454
|
+
def _latest_rows(self, account_id: int, target: PolicyTarget) -> list[sqlite3.Row]:
|
|
455
|
+
if target.scoped:
|
|
456
|
+
return self._store.query(
|
|
457
|
+
_SCOPED_LATEST, (int(account_id), target.type.value, target.id)
|
|
458
|
+
)
|
|
459
|
+
return self._store.query(_ORG_LATEST, (int(account_id),))
|
|
460
|
+
|
|
461
|
+
def _version_rows(
|
|
462
|
+
self, account_id: int, target: PolicyTarget, version: int
|
|
463
|
+
) -> list[sqlite3.Row]:
|
|
464
|
+
if target.scoped:
|
|
465
|
+
return self._store.query(
|
|
466
|
+
_SCOPED_VERSION, (int(account_id), target.type.value, target.id, int(version))
|
|
467
|
+
)
|
|
468
|
+
return self._store.query(_ORG_VERSION, (int(account_id), int(version)))
|
|
469
|
+
|
|
470
|
+
@staticmethod
|
|
471
|
+
def _max_version(db: sqlite3.Connection, account_id: int, target: PolicyTarget) -> int:
|
|
472
|
+
if target.scoped:
|
|
473
|
+
row = db.execute(
|
|
474
|
+
_SCOPED_MAX, (int(account_id), target.type.value, target.id)
|
|
475
|
+
).fetchone()
|
|
476
|
+
else:
|
|
477
|
+
row = db.execute(_ORG_MAX, (int(account_id),)).fetchone()
|
|
478
|
+
return int(row["latest"] or 0)
|
|
479
|
+
|
|
480
|
+
@staticmethod
|
|
481
|
+
def _insert_version(
|
|
482
|
+
db: sqlite3.Connection,
|
|
483
|
+
*,
|
|
484
|
+
account_id: int,
|
|
485
|
+
target: PolicyTarget,
|
|
486
|
+
version: int,
|
|
487
|
+
document: str,
|
|
488
|
+
now: datetime,
|
|
489
|
+
actor: Actor,
|
|
490
|
+
reason: str | None,
|
|
491
|
+
kind: str = "change",
|
|
492
|
+
rollback_of: int | None = None,
|
|
493
|
+
restored_version: int | None = None,
|
|
494
|
+
draft_id: str | None = None,
|
|
495
|
+
emergency: bool = False,
|
|
496
|
+
) -> None:
|
|
497
|
+
values = (
|
|
498
|
+
version,
|
|
499
|
+
document,
|
|
500
|
+
sha256_hex(document.encode("utf-8")),
|
|
501
|
+
now.timestamp(),
|
|
502
|
+
actor.id,
|
|
503
|
+
actor.login,
|
|
504
|
+
reason,
|
|
505
|
+
kind,
|
|
506
|
+
rollback_of,
|
|
507
|
+
restored_version,
|
|
508
|
+
draft_id,
|
|
509
|
+
1 if emergency else 0,
|
|
510
|
+
)
|
|
511
|
+
if target.scoped:
|
|
512
|
+
db.execute(_SCOPED_INSERT, (int(account_id), target.type.value, target.id, *values))
|
|
513
|
+
else:
|
|
514
|
+
db.execute(_ORG_INSERT, (int(account_id), *values))
|
|
515
|
+
|
|
516
|
+
# -- reads ----------------------------------------------------------- #
|
|
517
|
+
def current(self, account_id: int, target: PolicyTarget = ORGANIZATION_TARGET) -> PolicyVersion:
|
|
518
|
+
rows = self._latest_rows(account_id, target)
|
|
519
|
+
if not rows:
|
|
520
|
+
return PolicyVersion(account_id, 0, {}, None, None, None, None, None, target=target)
|
|
521
|
+
return self._version(rows[0], target)
|
|
522
|
+
|
|
523
|
+
def version(
|
|
524
|
+
self, account_id: int, version: int, target: PolicyTarget = ORGANIZATION_TARGET
|
|
525
|
+
) -> PolicyVersion | None:
|
|
526
|
+
rows = self._version_rows(account_id, target, version)
|
|
527
|
+
return self._version(rows[0], target) if rows else None
|
|
528
|
+
|
|
529
|
+
def versions(
|
|
530
|
+
self,
|
|
531
|
+
account_id: int,
|
|
532
|
+
*,
|
|
533
|
+
offset: int,
|
|
534
|
+
limit: int,
|
|
535
|
+
target: PolicyTarget = ORGANIZATION_TARGET,
|
|
536
|
+
) -> list[PolicyVersionView]:
|
|
537
|
+
"""Newest first. Each version is summarised against the version before it."""
|
|
538
|
+
if target.scoped:
|
|
539
|
+
rows = self._store.query(
|
|
540
|
+
_SCOPED_PAGE,
|
|
541
|
+
(int(account_id), target.type.value, target.id, int(limit) + 1, int(offset)),
|
|
542
|
+
)
|
|
543
|
+
else:
|
|
544
|
+
rows = self._store.query(_ORG_PAGE, (int(account_id), int(limit) + 1, int(offset)))
|
|
545
|
+
versions = [self._version(row, target) for row in rows]
|
|
546
|
+
latest = self.current(account_id, target).version
|
|
547
|
+
views = []
|
|
548
|
+
for index, version in enumerate(versions[:limit]):
|
|
549
|
+
if index + 1 < len(versions):
|
|
550
|
+
previous: PolicyVersion | None = versions[index + 1]
|
|
551
|
+
elif version.version > 1:
|
|
552
|
+
previous = self.version(account_id, version.version - 1, target)
|
|
553
|
+
else:
|
|
554
|
+
previous = None
|
|
555
|
+
views.append(
|
|
556
|
+
self.version_view(
|
|
557
|
+
version,
|
|
558
|
+
previous=previous.floors if previous else {},
|
|
559
|
+
previous_defaults=previous.defaults if previous else {},
|
|
560
|
+
active=version.version == latest,
|
|
561
|
+
)
|
|
562
|
+
)
|
|
563
|
+
return views
|
|
564
|
+
|
|
565
|
+
@staticmethod
|
|
566
|
+
def _version(row: sqlite3.Row, target: PolicyTarget = ORGANIZATION_TARGET) -> PolicyVersion:
|
|
567
|
+
try:
|
|
568
|
+
floors, defaults = parse_document(str(row["document"]))
|
|
569
|
+
except (ValueError, KeyError):
|
|
570
|
+
floors, defaults = {}, {}
|
|
571
|
+
keys = row.keys()
|
|
572
|
+
return PolicyVersion(
|
|
573
|
+
account_id=int(row["account_id"]),
|
|
574
|
+
version=int(row["version"]),
|
|
575
|
+
floors=floors,
|
|
576
|
+
fingerprint=str(row["fingerprint"]),
|
|
577
|
+
created_at=datetime.fromtimestamp(float(str(row["created_at"])), UTC),
|
|
578
|
+
created_by_id=int(str(row["created_by_id"]))
|
|
579
|
+
if row["created_by_id"] is not None
|
|
580
|
+
else None,
|
|
581
|
+
created_by_login=str(row["created_by_login"]) if row["created_by_login"] else None,
|
|
582
|
+
reason=str(row["reason"]) if row["reason"] else None,
|
|
583
|
+
kind=str(row["kind"] or "change"),
|
|
584
|
+
rollback_of=int(row["rollback_of"]) if row["rollback_of"] is not None else None,
|
|
585
|
+
restored_version=int(row["restored_version"])
|
|
586
|
+
if row["restored_version"] is not None
|
|
587
|
+
else None,
|
|
588
|
+
document=str(row["document"]),
|
|
589
|
+
defaults=defaults,
|
|
590
|
+
target=target,
|
|
591
|
+
draft_id=str(row["draft_id"]) if "draft_id" in keys and row["draft_id"] else None,
|
|
592
|
+
emergency=bool(row["emergency"]) if "emergency" in keys else False,
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
def version_view(
|
|
596
|
+
self,
|
|
597
|
+
version: PolicyVersion,
|
|
598
|
+
*,
|
|
599
|
+
previous: Mapping[str, Action] | None = None,
|
|
600
|
+
previous_defaults: Mapping[str, Action] | None = None,
|
|
601
|
+
active: bool | None = None,
|
|
602
|
+
) -> PolicyVersionView:
|
|
603
|
+
if previous is None:
|
|
604
|
+
before = (
|
|
605
|
+
self.version(version.account_id, version.version - 1, version.target)
|
|
606
|
+
if version.version > 1
|
|
607
|
+
else None
|
|
608
|
+
)
|
|
609
|
+
previous = before.floors if before else {}
|
|
610
|
+
previous_defaults = before.defaults if before else {}
|
|
611
|
+
if active is None:
|
|
612
|
+
active = self.current(version.account_id, version.target).version == version.version
|
|
613
|
+
changes = policy_changes(
|
|
614
|
+
previous, version.floors, previous_defaults or {}, version.defaults
|
|
615
|
+
)
|
|
616
|
+
return PolicyVersionView(
|
|
617
|
+
version=version.version,
|
|
618
|
+
fingerprint=version.fingerprint or "",
|
|
619
|
+
floors=dict(version.floors),
|
|
620
|
+
created_at=version.created_at or datetime.fromtimestamp(0, UTC),
|
|
621
|
+
created_by=PolicyAuthor(id=version.created_by_id, login=version.created_by_login),
|
|
622
|
+
reason=version.reason,
|
|
623
|
+
status="active" if active else "archived",
|
|
624
|
+
kind=version.kind, # type: ignore[arg-type]
|
|
625
|
+
rollback_of=version.rollback_of,
|
|
626
|
+
restored_version=version.restored_version,
|
|
627
|
+
changes=tuple(changes),
|
|
628
|
+
summary=change_summary(changes) or "No policy changes",
|
|
629
|
+
defaults=dict(version.defaults),
|
|
630
|
+
draft_id=version.draft_id,
|
|
631
|
+
emergency=version.emergency,
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
def diff(
|
|
635
|
+
self,
|
|
636
|
+
account_id: int,
|
|
637
|
+
from_version: int,
|
|
638
|
+
to_version: int,
|
|
639
|
+
target: PolicyTarget = ORGANIZATION_TARGET,
|
|
640
|
+
) -> PolicyDiffView | None:
|
|
641
|
+
"""Diff two versions (version 0 is "no policy")."""
|
|
642
|
+
old = self.version(account_id, from_version, target) if from_version else None
|
|
643
|
+
new = self.version(account_id, to_version, target) if to_version else None
|
|
644
|
+
if (from_version and old is None) or (to_version and new is None):
|
|
645
|
+
return None
|
|
646
|
+
return policy_diff(
|
|
647
|
+
from_version,
|
|
648
|
+
old.floors if old else {},
|
|
649
|
+
to_version,
|
|
650
|
+
new.floors if new else {},
|
|
651
|
+
old.defaults if old else {},
|
|
652
|
+
new.defaults if new else {},
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
def service_floors(self) -> dict[str, Action]:
|
|
656
|
+
if self._service_policy is None:
|
|
657
|
+
return {}
|
|
658
|
+
return {
|
|
659
|
+
policy_id: override.action or DEFAULT_POLICIES[policy_id].action
|
|
660
|
+
for policy_id, override in self._service_policy.config.policies.items()
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
def view(self, organization: OrganizationRef, *, can_write: bool) -> OrganizationPolicyView:
|
|
664
|
+
current = self.current(organization.id)
|
|
665
|
+
service = self.service_floors()
|
|
666
|
+
rules = []
|
|
667
|
+
for policy_id, policy in DEFAULT_POLICIES.items():
|
|
668
|
+
org_floor = current.floors.get(policy_id)
|
|
669
|
+
service_floor = service.get(policy_id)
|
|
670
|
+
floors = [a for a in (org_floor, service_floor) if a is not None]
|
|
671
|
+
minimum = Action.most_restrictive(floors) if floors else None
|
|
672
|
+
org_default = current.defaults.get(policy_id)
|
|
673
|
+
source: Literal[
|
|
674
|
+
"built_in_default", "service_policy", "organization_policy", "organization_default"
|
|
675
|
+
]
|
|
676
|
+
if minimum is None:
|
|
677
|
+
source = "organization_default" if org_default else "built_in_default"
|
|
678
|
+
elif (
|
|
679
|
+
service_floor is not None and service_floor.rank >= (org_floor or Action.ALLOW).rank
|
|
680
|
+
):
|
|
681
|
+
source = "service_policy"
|
|
682
|
+
else:
|
|
683
|
+
source = "organization_policy"
|
|
684
|
+
entry = CATALOG_BY_ID.get(policy_id)
|
|
685
|
+
rules.append(
|
|
686
|
+
PolicyRuleView(
|
|
687
|
+
policy_id=policy_id,
|
|
688
|
+
name=entry.name if entry else policy_id,
|
|
689
|
+
description=policy.description,
|
|
690
|
+
default_action=policy.action,
|
|
691
|
+
service_floor=service_floor,
|
|
692
|
+
organization_floor=org_floor,
|
|
693
|
+
minimum_action=minimum,
|
|
694
|
+
repository_override="any" if minimum is None else "stricter_only",
|
|
695
|
+
source=source,
|
|
696
|
+
organization_default=org_default,
|
|
697
|
+
)
|
|
698
|
+
)
|
|
699
|
+
return OrganizationPolicyView(
|
|
700
|
+
organization=organization,
|
|
701
|
+
version=current.version,
|
|
702
|
+
fingerprint=current.fingerprint,
|
|
703
|
+
updated_at=current.created_at,
|
|
704
|
+
updated_by=PolicyAuthor(id=current.created_by_id, login=current.created_by_login)
|
|
705
|
+
if current.version
|
|
706
|
+
else None,
|
|
707
|
+
reason=current.reason,
|
|
708
|
+
service_policy=self._service_policy.description if self._service_policy else None,
|
|
709
|
+
rules=tuple(rules),
|
|
710
|
+
can_write=can_write,
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
def repository_targets(self, account_id: int) -> list[int]:
|
|
714
|
+
"""Repositories of the account that have a dashboard repository policy."""
|
|
715
|
+
return [
|
|
716
|
+
int(r["target_id"])
|
|
717
|
+
for r in self._store.query(
|
|
718
|
+
"SELECT DISTINCT target_id FROM scoped_policy_versions WHERE account_id = ? "
|
|
719
|
+
"AND target_type = 'repository'",
|
|
720
|
+
(int(account_id),),
|
|
721
|
+
)
|
|
722
|
+
if str(r["target_id"]).isdigit()
|
|
723
|
+
]
|
|
724
|
+
|
|
725
|
+
def target_label(self, account_id: int, target: PolicyTarget) -> str | None:
|
|
726
|
+
with self._store.transaction() as db:
|
|
727
|
+
return target_label(db, account_id, target)
|
|
728
|
+
|
|
729
|
+
def scoped_view(
|
|
730
|
+
self, organization: OrganizationRef, target: PolicyTarget, *, can_write: bool
|
|
731
|
+
) -> ScopedPolicyView:
|
|
732
|
+
label = self.target_label(organization.id, target)
|
|
733
|
+
if label is None:
|
|
734
|
+
raise NotFoundError()
|
|
735
|
+
current = self.current(organization.id, target)
|
|
736
|
+
rules = tuple(
|
|
737
|
+
ScopedRuleView(
|
|
738
|
+
policy_id=policy_id,
|
|
739
|
+
name=CATALOG_BY_ID[policy_id].name if policy_id in CATALOG_BY_ID else policy_id,
|
|
740
|
+
mandatory=current.floors.get(policy_id),
|
|
741
|
+
default=current.defaults.get(policy_id),
|
|
742
|
+
)
|
|
743
|
+
for policy_id in DEFAULT_POLICIES
|
|
744
|
+
)
|
|
745
|
+
return ScopedPolicyView(
|
|
746
|
+
organization=organization,
|
|
747
|
+
target=PolicyTargetView(type=target.type.value, id=target.id, label=label),
|
|
748
|
+
version=current.version,
|
|
749
|
+
fingerprint=current.fingerprint,
|
|
750
|
+
updated_at=current.created_at,
|
|
751
|
+
updated_by=PolicyAuthor(id=current.created_by_id, login=current.created_by_login)
|
|
752
|
+
if current.version
|
|
753
|
+
else None,
|
|
754
|
+
reason=current.reason,
|
|
755
|
+
rules=rules,
|
|
756
|
+
can_write=can_write,
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
# -- writes ---------------------------------------------------------- #
|
|
760
|
+
def update(
|
|
761
|
+
self,
|
|
762
|
+
*,
|
|
763
|
+
account_id: int,
|
|
764
|
+
actor: Actor,
|
|
765
|
+
authenticated_at: datetime,
|
|
766
|
+
expected_version: int,
|
|
767
|
+
floors: Mapping[str, Action],
|
|
768
|
+
reason: str | None,
|
|
769
|
+
confirm_weakening: bool,
|
|
770
|
+
defaults: Mapping[str, Action] | None = None,
|
|
771
|
+
target: PolicyTarget = ORGANIZATION_TARGET,
|
|
772
|
+
draft_id: str | None = None,
|
|
773
|
+
emergency: bool = False,
|
|
774
|
+
hooks: Sequence[PublishHook] = (),
|
|
775
|
+
) -> tuple[PolicyVersion, list[PolicyChange]]:
|
|
776
|
+
"""Publish a new version of ``target``'s policy.
|
|
777
|
+
|
|
778
|
+
``defaults=None`` keeps the current default entries (the Phase 6 API only
|
|
779
|
+
sends floors). The caller has already applied the organization's approval
|
|
780
|
+
requirements: this method publishes.
|
|
781
|
+
"""
|
|
782
|
+
now = self._now()
|
|
783
|
+
reason_text = (
|
|
784
|
+
clean_text(reason.strip(), MAX_REASON_CHARS) if reason and reason.strip() else None
|
|
785
|
+
)
|
|
786
|
+
current = self.current(account_id, target)
|
|
787
|
+
if current.version != expected_version:
|
|
788
|
+
raise ConflictError(
|
|
789
|
+
f"The policy was changed by someone else (now version {current.version}). "
|
|
790
|
+
"Reload to see the latest version before saving."
|
|
791
|
+
)
|
|
792
|
+
if defaults is None:
|
|
793
|
+
# Floors-only callers keep the defaults; a rule that became mandatory
|
|
794
|
+
# is no longer also a default.
|
|
795
|
+
new_defaults = {k: v for k, v in current.defaults.items() if k not in floors}
|
|
796
|
+
else:
|
|
797
|
+
new_defaults = dict(defaults)
|
|
798
|
+
changes = policy_changes(current.floors, floors, current.defaults, new_defaults)
|
|
799
|
+
if not changes:
|
|
800
|
+
raise InputValidationError("The new policy is identical to the current version.")
|
|
801
|
+
weakening = [c for c in changes if c.weakening]
|
|
802
|
+
if weakening and not confirm_weakening:
|
|
803
|
+
raise ConfirmationRequiredError(
|
|
804
|
+
"This change weakens enforcement and must be confirmed explicitly."
|
|
805
|
+
)
|
|
806
|
+
if weakening and now - authenticated_at > REAUTHENTICATION_WINDOW:
|
|
807
|
+
raise ReauthenticationRequiredError()
|
|
808
|
+
if (weakening or emergency) and reason_text is None:
|
|
809
|
+
raise InputValidationError(
|
|
810
|
+
"A reason is required when weakening enforcement or publishing in an emergency.",
|
|
811
|
+
field="reason",
|
|
812
|
+
)
|
|
813
|
+
document = canonical_document(floors, new_defaults)
|
|
814
|
+
if len(document.encode("utf-8")) > MAX_DOCUMENT_BYTES:
|
|
815
|
+
raise InputValidationError("The policy document is too large.")
|
|
816
|
+
summary = change_summary(changes)
|
|
817
|
+
with self._store.transaction() as db:
|
|
818
|
+
label = target_label(db, account_id, target)
|
|
819
|
+
if label is None:
|
|
820
|
+
raise NotFoundError()
|
|
821
|
+
latest = self._max_version(db, account_id, target)
|
|
822
|
+
if latest != expected_version:
|
|
823
|
+
raise ConflictError(
|
|
824
|
+
f"The policy was changed by someone else (now version {latest}). "
|
|
825
|
+
"Reload to see the latest version before saving."
|
|
826
|
+
)
|
|
827
|
+
self._insert_version(
|
|
828
|
+
db,
|
|
829
|
+
account_id=account_id,
|
|
830
|
+
target=target,
|
|
831
|
+
version=latest + 1,
|
|
832
|
+
document=document,
|
|
833
|
+
now=now,
|
|
834
|
+
actor=actor,
|
|
835
|
+
reason=reason_text,
|
|
836
|
+
draft_id=draft_id,
|
|
837
|
+
emergency=emergency,
|
|
838
|
+
)
|
|
839
|
+
target_data = self._target_data(target, label)
|
|
840
|
+
stored = [
|
|
841
|
+
self._store.insert_audit_event(
|
|
842
|
+
db,
|
|
843
|
+
self._audit.build(
|
|
844
|
+
AuditEventType.POLICY_PUBLISHED
|
|
845
|
+
if target.scoped
|
|
846
|
+
else AuditEventType.ORGANIZATION_POLICY_CHANGED,
|
|
847
|
+
actor=actor,
|
|
848
|
+
account_id=account_id,
|
|
849
|
+
old_version=latest,
|
|
850
|
+
new_version=latest + 1,
|
|
851
|
+
changes=summary,
|
|
852
|
+
weakening=bool(weakening),
|
|
853
|
+
reason=reason_text,
|
|
854
|
+
extra={**target_data, **({"draft": draft_id} if draft_id else {})},
|
|
855
|
+
),
|
|
856
|
+
)
|
|
857
|
+
]
|
|
858
|
+
title_label = "Organization policy" if not target.scoped else f"{label} policy"
|
|
859
|
+
emit(
|
|
860
|
+
db,
|
|
861
|
+
self._policy_notification(
|
|
862
|
+
NotificationType.POLICY_CHANGED,
|
|
863
|
+
account_id=account_id,
|
|
864
|
+
new_version=latest + 1,
|
|
865
|
+
title=f"{title_label} changed: v{latest} → v{latest + 1}",
|
|
866
|
+
body=(
|
|
867
|
+
f"{actor.login or 'An administrator'} published "
|
|
868
|
+
f"{title_label.lower()} v{latest + 1}. Changes: {summary}."
|
|
869
|
+
+ (" This change weakens enforcement." if weakening else "")
|
|
870
|
+
+ (f" Reason: {reason_text}" if reason_text else "")
|
|
871
|
+
),
|
|
872
|
+
weakening=bool(weakening),
|
|
873
|
+
target=target,
|
|
874
|
+
metadata={
|
|
875
|
+
"previous_version": latest,
|
|
876
|
+
"new_version": latest + 1,
|
|
877
|
+
"changed_by": actor.login,
|
|
878
|
+
"changes": summary,
|
|
879
|
+
"weakening": bool(weakening),
|
|
880
|
+
"reason": reason_text,
|
|
881
|
+
**target_data,
|
|
882
|
+
},
|
|
883
|
+
),
|
|
884
|
+
now,
|
|
885
|
+
)
|
|
886
|
+
if emergency:
|
|
887
|
+
stored.append(
|
|
888
|
+
self._store.insert_audit_event(
|
|
889
|
+
db,
|
|
890
|
+
self._audit.build(
|
|
891
|
+
AuditEventType.POLICY_EMERGENCY_PUBLISHED,
|
|
892
|
+
actor=actor,
|
|
893
|
+
account_id=account_id,
|
|
894
|
+
new_version=latest + 1,
|
|
895
|
+
changes=summary,
|
|
896
|
+
reason=reason_text,
|
|
897
|
+
extra=target_data,
|
|
898
|
+
),
|
|
899
|
+
)
|
|
900
|
+
)
|
|
901
|
+
emit(
|
|
902
|
+
db,
|
|
903
|
+
NotificationEvent(
|
|
904
|
+
type=NotificationType.POLICY_EMERGENCY_PUBLISHED,
|
|
905
|
+
account_id=account_id,
|
|
906
|
+
severity=Severity.CRITICAL,
|
|
907
|
+
resource_type="policy",
|
|
908
|
+
resource_id=str(account_id),
|
|
909
|
+
dedup_key=domain_key(
|
|
910
|
+
NotificationType.POLICY_EMERGENCY_PUBLISHED,
|
|
911
|
+
account_id,
|
|
912
|
+
target.type.value,
|
|
913
|
+
target.id or "-",
|
|
914
|
+
latest + 1,
|
|
915
|
+
),
|
|
916
|
+
title=f"Emergency policy publication: {title_label} v{latest + 1}",
|
|
917
|
+
body=(
|
|
918
|
+
f"{actor.login or 'An administrator'} published {title_label.lower()} "
|
|
919
|
+
f"v{latest + 1} without the approval workflow. Changes: {summary}. "
|
|
920
|
+
f"Reason: {reason_text}"
|
|
921
|
+
),
|
|
922
|
+
metadata={"new_version": latest + 1, "changes": summary, **target_data},
|
|
923
|
+
),
|
|
924
|
+
now,
|
|
925
|
+
)
|
|
926
|
+
published = PublishedPolicy(account_id, target, latest + 1, latest, "change", now)
|
|
927
|
+
for hook in (*self._publish_hooks, *hooks):
|
|
928
|
+
hook(db, published)
|
|
929
|
+
for event in stored:
|
|
930
|
+
self._audit.log_stored(event)
|
|
931
|
+
updated = self.version(account_id, latest + 1, target)
|
|
932
|
+
assert updated is not None # noqa: S101 - written in the transaction above
|
|
933
|
+
return updated, changes
|
|
934
|
+
|
|
935
|
+
@staticmethod
|
|
936
|
+
def _target_data(target: PolicyTarget, label: str) -> dict[str, str | int | bool | None]:
|
|
937
|
+
if not target.scoped:
|
|
938
|
+
return {}
|
|
939
|
+
return {"target_type": target.type.value, "target_id": target.id, "target": label}
|
|
940
|
+
|
|
941
|
+
@staticmethod
|
|
942
|
+
def _policy_notification(
|
|
943
|
+
notification_type: NotificationType,
|
|
944
|
+
*,
|
|
945
|
+
account_id: int,
|
|
946
|
+
new_version: int,
|
|
947
|
+
title: str,
|
|
948
|
+
body: str,
|
|
949
|
+
weakening: bool,
|
|
950
|
+
metadata: dict[str, str | int | bool | None],
|
|
951
|
+
target: PolicyTarget = ORGANIZATION_TARGET,
|
|
952
|
+
) -> NotificationEvent:
|
|
953
|
+
key = (
|
|
954
|
+
domain_key(notification_type, account_id, target.type.value, target.id, new_version)
|
|
955
|
+
if target.scoped
|
|
956
|
+
else domain_key(notification_type, account_id, new_version)
|
|
957
|
+
)
|
|
958
|
+
return NotificationEvent(
|
|
959
|
+
type=notification_type,
|
|
960
|
+
account_id=account_id,
|
|
961
|
+
severity=Severity.CRITICAL if weakening else Severity.HIGH,
|
|
962
|
+
resource_type="policy",
|
|
963
|
+
resource_id=str(account_id),
|
|
964
|
+
dedup_key=key,
|
|
965
|
+
title=title,
|
|
966
|
+
body=body,
|
|
967
|
+
metadata=metadata,
|
|
968
|
+
)
|
|
969
|
+
|
|
970
|
+
def rollback(
|
|
971
|
+
self,
|
|
972
|
+
*,
|
|
973
|
+
account_id: int,
|
|
974
|
+
actor: Actor,
|
|
975
|
+
authenticated_at: datetime,
|
|
976
|
+
target_version: int,
|
|
977
|
+
expected_current_version: int,
|
|
978
|
+
reason: str | None,
|
|
979
|
+
confirm: bool,
|
|
980
|
+
target: PolicyTarget = ORGANIZATION_TARGET,
|
|
981
|
+
hooks: Sequence[PublishHook] = (),
|
|
982
|
+
) -> tuple[PolicyVersion, PolicyDiffView]:
|
|
983
|
+
"""Publish a new version restoring ``target_version``'s document. Atomic."""
|
|
984
|
+
try:
|
|
985
|
+
return self._rollback(
|
|
986
|
+
account_id=account_id,
|
|
987
|
+
actor=actor,
|
|
988
|
+
authenticated_at=authenticated_at,
|
|
989
|
+
target_version=target_version,
|
|
990
|
+
expected_current_version=expected_current_version,
|
|
991
|
+
reason=reason,
|
|
992
|
+
confirm=confirm,
|
|
993
|
+
target=target,
|
|
994
|
+
hooks=hooks,
|
|
995
|
+
)
|
|
996
|
+
except Exception:
|
|
997
|
+
self._metrics.increment(POLICY_ROLLBACK_FAILURES)
|
|
998
|
+
raise
|
|
999
|
+
|
|
1000
|
+
def _rollback(
|
|
1001
|
+
self,
|
|
1002
|
+
*,
|
|
1003
|
+
account_id: int,
|
|
1004
|
+
actor: Actor,
|
|
1005
|
+
authenticated_at: datetime,
|
|
1006
|
+
target_version: int,
|
|
1007
|
+
expected_current_version: int,
|
|
1008
|
+
reason: str | None,
|
|
1009
|
+
confirm: bool,
|
|
1010
|
+
target: PolicyTarget,
|
|
1011
|
+
hooks: Sequence[PublishHook],
|
|
1012
|
+
) -> tuple[PolicyVersion, PolicyDiffView]:
|
|
1013
|
+
now = self._now()
|
|
1014
|
+
reason_text = (
|
|
1015
|
+
clean_text(reason.strip(), MAX_REASON_CHARS) if reason and reason.strip() else None
|
|
1016
|
+
)
|
|
1017
|
+
if reason_text is None:
|
|
1018
|
+
raise InputValidationError("A reason is required to roll back policy.", field="reason")
|
|
1019
|
+
current = self.current(account_id, target)
|
|
1020
|
+
if current.version != expected_current_version:
|
|
1021
|
+
raise ConflictError(
|
|
1022
|
+
f"The policy was changed by someone else (now version {current.version}). "
|
|
1023
|
+
"Reload to see the latest version before rolling back."
|
|
1024
|
+
)
|
|
1025
|
+
if not 1 <= target_version < current.version:
|
|
1026
|
+
raise InputValidationError(
|
|
1027
|
+
"The target must be an earlier version of this policy.",
|
|
1028
|
+
field="target_version",
|
|
1029
|
+
)
|
|
1030
|
+
restored = self.version(account_id, target_version, target)
|
|
1031
|
+
if restored is None:
|
|
1032
|
+
raise InputValidationError(
|
|
1033
|
+
f"Version {target_version} does not exist.", field="target_version"
|
|
1034
|
+
)
|
|
1035
|
+
self._verify_integrity(restored)
|
|
1036
|
+
diff = policy_diff(
|
|
1037
|
+
current.version,
|
|
1038
|
+
current.floors,
|
|
1039
|
+
target_version,
|
|
1040
|
+
restored.floors,
|
|
1041
|
+
current.defaults,
|
|
1042
|
+
restored.defaults,
|
|
1043
|
+
)
|
|
1044
|
+
if not (diff.added or diff.changed or diff.removed):
|
|
1045
|
+
raise InputValidationError(
|
|
1046
|
+
f"Version {target_version} is identical to the active version.",
|
|
1047
|
+
field="target_version",
|
|
1048
|
+
)
|
|
1049
|
+
if not confirm:
|
|
1050
|
+
raise ConfirmationRequiredError(
|
|
1051
|
+
"Rolling back changes the effective security policy and must be confirmed."
|
|
1052
|
+
)
|
|
1053
|
+
if diff.weakening and now - authenticated_at > REAUTHENTICATION_WINDOW:
|
|
1054
|
+
raise ReauthenticationRequiredError()
|
|
1055
|
+
assert restored.document is not None # noqa: S101 - read from the database
|
|
1056
|
+
changes = policy_changes(
|
|
1057
|
+
current.floors, restored.floors, current.defaults, restored.defaults
|
|
1058
|
+
)
|
|
1059
|
+
summary = change_summary(changes)
|
|
1060
|
+
with self._store.transaction() as db:
|
|
1061
|
+
label = target_label(db, account_id, target)
|
|
1062
|
+
if label is None:
|
|
1063
|
+
raise NotFoundError()
|
|
1064
|
+
latest = self._max_version(db, account_id, target)
|
|
1065
|
+
if latest != expected_current_version:
|
|
1066
|
+
raise ConflictError(
|
|
1067
|
+
f"The policy was changed by someone else (now version {latest}). "
|
|
1068
|
+
"Reload to see the latest version before rolling back."
|
|
1069
|
+
)
|
|
1070
|
+
new_version = latest + 1
|
|
1071
|
+
self._insert_version(
|
|
1072
|
+
db,
|
|
1073
|
+
account_id=account_id,
|
|
1074
|
+
target=target,
|
|
1075
|
+
version=new_version,
|
|
1076
|
+
document=restored.document,
|
|
1077
|
+
now=now,
|
|
1078
|
+
actor=actor,
|
|
1079
|
+
reason=reason_text,
|
|
1080
|
+
kind="rollback",
|
|
1081
|
+
rollback_of=latest,
|
|
1082
|
+
restored_version=target_version,
|
|
1083
|
+
)
|
|
1084
|
+
target_data = self._target_data(target, label)
|
|
1085
|
+
stored = self._store.insert_audit_event(
|
|
1086
|
+
db,
|
|
1087
|
+
self._audit.build(
|
|
1088
|
+
AuditEventType.POLICY_ROLLED_BACK
|
|
1089
|
+
if target.scoped
|
|
1090
|
+
else AuditEventType.ORGANIZATION_POLICY_ROLLED_BACK,
|
|
1091
|
+
actor=actor,
|
|
1092
|
+
account_id=account_id,
|
|
1093
|
+
previous_version=latest,
|
|
1094
|
+
target_version=target_version,
|
|
1095
|
+
new_version=new_version,
|
|
1096
|
+
changes=summary,
|
|
1097
|
+
weakening=diff.weakening,
|
|
1098
|
+
reason=reason_text,
|
|
1099
|
+
extra=target_data,
|
|
1100
|
+
),
|
|
1101
|
+
)
|
|
1102
|
+
title_label = "Organization policy" if not target.scoped else f"{label} policy"
|
|
1103
|
+
emit(
|
|
1104
|
+
db,
|
|
1105
|
+
self._policy_notification(
|
|
1106
|
+
NotificationType.POLICY_ROLLED_BACK,
|
|
1107
|
+
account_id=account_id,
|
|
1108
|
+
new_version=new_version,
|
|
1109
|
+
title=(
|
|
1110
|
+
f"{title_label} rolled back: v{latest} → v{new_version} "
|
|
1111
|
+
f"(restores v{target_version})"
|
|
1112
|
+
),
|
|
1113
|
+
body=(
|
|
1114
|
+
f"{actor.login or 'An administrator'} rolled back the "
|
|
1115
|
+
f"{title_label.lower()}. Previous: v{latest}. Restored: v{target_version}. "
|
|
1116
|
+
f"Effective: v{new_version}. Changes: {summary}. Reason: {reason_text}"
|
|
1117
|
+
),
|
|
1118
|
+
weakening=diff.weakening,
|
|
1119
|
+
target=target,
|
|
1120
|
+
metadata={
|
|
1121
|
+
"previous_version": latest,
|
|
1122
|
+
"target_version": target_version,
|
|
1123
|
+
"new_version": new_version,
|
|
1124
|
+
"changed_by": actor.login,
|
|
1125
|
+
"changes": summary,
|
|
1126
|
+
"weakening": diff.weakening,
|
|
1127
|
+
"reason": reason_text,
|
|
1128
|
+
**target_data,
|
|
1129
|
+
},
|
|
1130
|
+
),
|
|
1131
|
+
now,
|
|
1132
|
+
)
|
|
1133
|
+
published = PublishedPolicy(account_id, target, new_version, latest, "rollback", now)
|
|
1134
|
+
for hook in (*self._publish_hooks, *hooks):
|
|
1135
|
+
hook(db, published)
|
|
1136
|
+
self._audit.log_stored(stored)
|
|
1137
|
+
self._metrics.increment(POLICY_ROLLBACKS)
|
|
1138
|
+
created = self.version(account_id, new_version, target)
|
|
1139
|
+
assert created is not None # noqa: S101 - written in the transaction above
|
|
1140
|
+
return created, diff
|
|
1141
|
+
|
|
1142
|
+
@staticmethod
|
|
1143
|
+
def _verify_integrity(version: PolicyVersion) -> None:
|
|
1144
|
+
document = version.document or ""
|
|
1145
|
+
if sha256_hex(document.encode("utf-8")) != version.fingerprint:
|
|
1146
|
+
raise PolicyIntegrityError(
|
|
1147
|
+
f"Version {version.version} failed its integrity check and cannot be restored."
|
|
1148
|
+
)
|
|
1149
|
+
try:
|
|
1150
|
+
floors, defaults = parse_document(document)
|
|
1151
|
+
validate_floors({k: v.value for k, v in floors.items()})
|
|
1152
|
+
canonical = canonical_document(floors, defaults)
|
|
1153
|
+
except (ValueError, InputValidationError):
|
|
1154
|
+
raise PolicyIntegrityError(
|
|
1155
|
+
f"Version {version.version} is not a valid policy and cannot be restored."
|
|
1156
|
+
) from None
|
|
1157
|
+
if canonical != document:
|
|
1158
|
+
raise PolicyIntegrityError(
|
|
1159
|
+
f"Version {version.version} is not in canonical form and cannot be restored."
|
|
1160
|
+
)
|
|
1161
|
+
|
|
1162
|
+
# -- enforcement ----------------------------------------------------- #
|
|
1163
|
+
def mandatory_for_installation(
|
|
1164
|
+
self, installation_id: int
|
|
1165
|
+
) -> tuple[MandatoryPolicy | None, int | None]:
|
|
1166
|
+
"""The combined floor (service + organisation) for an installation.
|
|
1167
|
+
|
|
1168
|
+
Kept for adapters without organization governance; the GitHub App resolves
|
|
1169
|
+
the complete governance inputs per repository instead
|
|
1170
|
+
(:class:`commitguard.governance.resolver.GovernanceResolver`).
|
|
1171
|
+
"""
|
|
1172
|
+
rows = self._store.query(
|
|
1173
|
+
"SELECT account_id FROM installations WHERE installation_id = ?",
|
|
1174
|
+
(int(installation_id),),
|
|
1175
|
+
)
|
|
1176
|
+
organization = self.current(int(rows[0]["account_id"])) if rows else None
|
|
1177
|
+
org_floors = dict(organization.floors) if organization else {}
|
|
1178
|
+
service = self.service_floors()
|
|
1179
|
+
combined = {
|
|
1180
|
+
policy_id: Action.most_restrictive(
|
|
1181
|
+
[a for a in (org_floors.get(policy_id), service.get(policy_id)) if a is not None]
|
|
1182
|
+
)
|
|
1183
|
+
for policy_id in sorted(set(org_floors) | set(service))
|
|
1184
|
+
}
|
|
1185
|
+
version = organization.version if organization and organization.version else None
|
|
1186
|
+
if not combined:
|
|
1187
|
+
return None, version
|
|
1188
|
+
parts = []
|
|
1189
|
+
if self._service_policy is not None:
|
|
1190
|
+
parts.append(f"service policy {self._service_policy.description}")
|
|
1191
|
+
if version:
|
|
1192
|
+
parts.append(f"organization policy v{version}")
|
|
1193
|
+
config = CommitGuardConfig(
|
|
1194
|
+
version=1,
|
|
1195
|
+
policies={pid: PolicyOverride(action=action) for pid, action in combined.items()},
|
|
1196
|
+
)
|
|
1197
|
+
return (
|
|
1198
|
+
MandatoryPolicy(
|
|
1199
|
+
config=config,
|
|
1200
|
+
description=" + ".join(parts),
|
|
1201
|
+
fingerprint=sha256_hex(_canonical(combined).encode("utf-8")),
|
|
1202
|
+
),
|
|
1203
|
+
version,
|
|
1204
|
+
)
|