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,734 @@
|
|
|
1
|
+
"""Policy workflow: draft -> simulate -> review -> approve -> publish.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
draft ──submit──► pending_approval ──approve──► approved ──publish──► published
|
|
6
|
+
│ │ │ │
|
|
7
|
+
│ └──reject──► rejected └──edit──► draft └─ immutable
|
|
8
|
+
└──cancel──► cancelled version
|
|
9
|
+
|
|
10
|
+
A draft holds a *proposed* document for one policy target (organization,
|
|
11
|
+
repository group or repository). Nothing about it is enforced: the effective
|
|
12
|
+
policy changes only when a draft is published, which writes an immutable
|
|
13
|
+
version through :class:`~commitguard.controlplane.policies.OrganizationPolicyService`
|
|
14
|
+
(the Phase 6/7 code path, with its confirmation, re-authentication, conflict
|
|
15
|
+
and rollback rules).
|
|
16
|
+
|
|
17
|
+
Whether approval is required is an organization setting. With
|
|
18
|
+
``require_policy_approval`` off, an administrator with ``policies:publish`` can
|
|
19
|
+
publish a draft directly (and the Phase 6 "save policy" endpoint keeps working).
|
|
20
|
+
With it on, a draft must be approved first, and direct saves are refused with
|
|
21
|
+
``APPROVAL_REQUIRED``.
|
|
22
|
+
|
|
23
|
+
Separation of duties (``require_separate_approver``, on by default): the person
|
|
24
|
+
who created or submitted a draft cannot approve it. Approval binds to the
|
|
25
|
+
draft's document fingerprint - editing an approved draft cancels the approval,
|
|
26
|
+
so no one can get one document approved and publish another.
|
|
27
|
+
|
|
28
|
+
Emergency publication (``policies:emergency``, owners) skips the approval
|
|
29
|
+
workflow. It requires a reason, is recorded on the version itself, and produces
|
|
30
|
+
a critical audit event and notification: it is never silent.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import sqlite3
|
|
34
|
+
from collections.abc import Callable, Mapping
|
|
35
|
+
from datetime import UTC, datetime
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
from pydantic import BaseModel, ConfigDict
|
|
39
|
+
|
|
40
|
+
from commitguard.audit.models import Actor, AuditEventType
|
|
41
|
+
from commitguard.controlplane.access import Permission, Principal
|
|
42
|
+
from commitguard.controlplane.errors import (
|
|
43
|
+
ApprovalRequiredError,
|
|
44
|
+
ConflictError,
|
|
45
|
+
InputValidationError,
|
|
46
|
+
NotFoundError,
|
|
47
|
+
PermissionDeniedError,
|
|
48
|
+
)
|
|
49
|
+
from commitguard.controlplane.policies import (
|
|
50
|
+
MAX_DOCUMENT_BYTES,
|
|
51
|
+
ORGANIZATION_TARGET,
|
|
52
|
+
OrganizationPolicyService,
|
|
53
|
+
PolicyTarget,
|
|
54
|
+
PolicyTargetType,
|
|
55
|
+
PublishedPolicy,
|
|
56
|
+
canonical_document,
|
|
57
|
+
change_summary,
|
|
58
|
+
parse_document,
|
|
59
|
+
policy_changes,
|
|
60
|
+
policy_diff,
|
|
61
|
+
target_label,
|
|
62
|
+
validate_defaults,
|
|
63
|
+
validate_floors,
|
|
64
|
+
)
|
|
65
|
+
from commitguard.controlplane.views import PolicyChange, PolicyDiffView, PolicyTargetView
|
|
66
|
+
from commitguard.core.decision import Action
|
|
67
|
+
from commitguard.core.result import Severity
|
|
68
|
+
from commitguard.github.storage import SqliteStateStore
|
|
69
|
+
from commitguard.governance.common import (
|
|
70
|
+
MAX_NAME_CHARS,
|
|
71
|
+
MAX_REASON_CHARS,
|
|
72
|
+
dt,
|
|
73
|
+
is_hex_id,
|
|
74
|
+
new_id,
|
|
75
|
+
req_dt,
|
|
76
|
+
require,
|
|
77
|
+
text,
|
|
78
|
+
ts,
|
|
79
|
+
visible_repository_ids,
|
|
80
|
+
)
|
|
81
|
+
from commitguard.governance.settings import load_settings
|
|
82
|
+
from commitguard.notifications.deduplication import domain_key
|
|
83
|
+
from commitguard.notifications.models import NotificationEvent, NotificationType
|
|
84
|
+
from commitguard.notifications.outbox import emit
|
|
85
|
+
from commitguard.security.hashing import sha256_hex
|
|
86
|
+
from commitguard.services.audit import AuditService
|
|
87
|
+
|
|
88
|
+
DRAFT_STATES = ("draft", "pending_approval", "approved", "rejected", "cancelled", "published")
|
|
89
|
+
OPEN_DRAFT_STATES = ("draft", "pending_approval", "approved")
|
|
90
|
+
MAX_OPEN_DRAFTS = 50
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class PolicyApprovalView(BaseModel):
|
|
94
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
95
|
+
|
|
96
|
+
id: str
|
|
97
|
+
status: str
|
|
98
|
+
requested_by: str | None
|
|
99
|
+
requested_at: datetime
|
|
100
|
+
decided_by: str | None
|
|
101
|
+
decided_at: datetime | None
|
|
102
|
+
reason: str | None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class PolicyDraftView(BaseModel):
|
|
106
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
107
|
+
|
|
108
|
+
id: str
|
|
109
|
+
organization_id: int
|
|
110
|
+
target: PolicyTargetView
|
|
111
|
+
title: str
|
|
112
|
+
reason: str | None
|
|
113
|
+
state: str
|
|
114
|
+
revision: int
|
|
115
|
+
base_version: int
|
|
116
|
+
current_version: int # the target's published version right now
|
|
117
|
+
floors: dict[str, Action]
|
|
118
|
+
defaults: dict[str, Action]
|
|
119
|
+
changes: tuple[PolicyChange, ...]
|
|
120
|
+
diff: PolicyDiffView
|
|
121
|
+
weakening: bool
|
|
122
|
+
rebase_required: bool
|
|
123
|
+
requires_approval: bool
|
|
124
|
+
created_by: str | None
|
|
125
|
+
created_at: datetime
|
|
126
|
+
updated_at: datetime
|
|
127
|
+
submitted_by: str | None
|
|
128
|
+
submitted_at: datetime | None
|
|
129
|
+
published_version: int | None
|
|
130
|
+
published_at: datetime | None
|
|
131
|
+
published_by: str | None
|
|
132
|
+
emergency: bool
|
|
133
|
+
rollout_id: str | None
|
|
134
|
+
approvals: tuple[PolicyApprovalView, ...]
|
|
135
|
+
can_edit: bool
|
|
136
|
+
can_submit: bool
|
|
137
|
+
can_approve: bool
|
|
138
|
+
can_publish: bool
|
|
139
|
+
can_cancel: bool
|
|
140
|
+
can_emergency_publish: bool
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def parse_target(target_type: object, target_id: object) -> PolicyTarget:
|
|
144
|
+
if not isinstance(target_type, str) or target_type not in {t.value for t in PolicyTargetType}:
|
|
145
|
+
raise InputValidationError(
|
|
146
|
+
"target_type must be organization, group or repository", field="target_type"
|
|
147
|
+
)
|
|
148
|
+
kind = PolicyTargetType(target_type)
|
|
149
|
+
if kind is PolicyTargetType.ORGANIZATION:
|
|
150
|
+
if target_id not in (None, ""):
|
|
151
|
+
raise InputValidationError("an organization policy has no target_id", field="target_id")
|
|
152
|
+
return ORGANIZATION_TARGET
|
|
153
|
+
if kind is PolicyTargetType.GROUP:
|
|
154
|
+
if not is_hex_id(target_id):
|
|
155
|
+
raise InputValidationError("target_id must be a group ID", field="target_id")
|
|
156
|
+
assert isinstance(target_id, str) # noqa: S101 - checked above
|
|
157
|
+
return PolicyTarget.group(target_id)
|
|
158
|
+
if isinstance(target_id, bool) or not isinstance(target_id, int):
|
|
159
|
+
if isinstance(target_id, str) and target_id.isdigit() and len(target_id) < 17:
|
|
160
|
+
target_id = int(target_id)
|
|
161
|
+
else:
|
|
162
|
+
raise InputValidationError("target_id must be a repository ID", field="target_id")
|
|
163
|
+
return PolicyTarget.repository(int(target_id))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def parse_rules(body: Mapping[str, Any]) -> tuple[dict[str, Action], dict[str, Action]]:
|
|
167
|
+
"""``{"floors": {...}, "defaults": {...}}`` -> mandatory and default entries."""
|
|
168
|
+
floors = validate_floors(body.get("floors") or {})
|
|
169
|
+
defaults = validate_defaults(body.get("defaults") or {})
|
|
170
|
+
overlap = sorted(set(floors) & set(defaults))
|
|
171
|
+
if overlap:
|
|
172
|
+
raise InputValidationError(
|
|
173
|
+
f"a rule is either mandatory or a default, not both: {', '.join(overlap)}",
|
|
174
|
+
field="defaults",
|
|
175
|
+
)
|
|
176
|
+
if not floors and not defaults:
|
|
177
|
+
raise InputValidationError("a policy must set at least one rule", field="floors")
|
|
178
|
+
return floors, defaults
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class PolicyWorkflowService:
|
|
182
|
+
def __init__(
|
|
183
|
+
self,
|
|
184
|
+
store: SqliteStateStore,
|
|
185
|
+
audit: AuditService,
|
|
186
|
+
policies: OrganizationPolicyService,
|
|
187
|
+
*,
|
|
188
|
+
now: Callable[[], datetime] = lambda: datetime.now(UTC),
|
|
189
|
+
) -> None:
|
|
190
|
+
self._store = store
|
|
191
|
+
self._audit = audit
|
|
192
|
+
self._policies = policies
|
|
193
|
+
self._now = now
|
|
194
|
+
|
|
195
|
+
# -- helpers ---------------------------------------------------------- #
|
|
196
|
+
def _target_of(self, row: sqlite3.Row) -> PolicyTarget:
|
|
197
|
+
return PolicyTarget(PolicyTargetType(row["target_type"]), str(row["target_id"]))
|
|
198
|
+
|
|
199
|
+
def _label(self, account_id: int, target: PolicyTarget) -> str:
|
|
200
|
+
with self._store.transaction() as db:
|
|
201
|
+
label = target_label(db, account_id, target)
|
|
202
|
+
if label is None:
|
|
203
|
+
raise NotFoundError()
|
|
204
|
+
return label
|
|
205
|
+
|
|
206
|
+
def _require_target_access(
|
|
207
|
+
self, principal: Principal, account_id: int, target: PolicyTarget
|
|
208
|
+
) -> str:
|
|
209
|
+
label = self._label(account_id, target)
|
|
210
|
+
if target.type is PolicyTargetType.REPOSITORY:
|
|
211
|
+
visible = visible_repository_ids(self._store, principal, account_id)
|
|
212
|
+
if int(target.id) not in visible:
|
|
213
|
+
raise NotFoundError()
|
|
214
|
+
return label
|
|
215
|
+
|
|
216
|
+
def _draft_row(self, draft_id: str) -> sqlite3.Row:
|
|
217
|
+
if not is_hex_id(draft_id):
|
|
218
|
+
raise NotFoundError()
|
|
219
|
+
rows = self._store.query("SELECT * FROM policy_drafts WHERE draft_id = ?", (draft_id,))
|
|
220
|
+
if not rows:
|
|
221
|
+
raise NotFoundError()
|
|
222
|
+
return rows[0]
|
|
223
|
+
|
|
224
|
+
def _authorized_draft(
|
|
225
|
+
self, principal: Principal, draft_id: str, permission: Permission
|
|
226
|
+
) -> sqlite3.Row:
|
|
227
|
+
row = self._draft_row(draft_id)
|
|
228
|
+
account_id = int(row["account_id"])
|
|
229
|
+
require(principal, Permission.POLICIES_READ, account_id)
|
|
230
|
+
self._require_target_access(principal, account_id, self._target_of(row))
|
|
231
|
+
if not principal.can(permission, account_id):
|
|
232
|
+
raise PermissionDeniedError()
|
|
233
|
+
return row
|
|
234
|
+
|
|
235
|
+
def authorize(self, principal: Principal, draft_id: str, permission: Permission) -> int:
|
|
236
|
+
"""The draft's organization after checking ``permission`` (404 across tenants)."""
|
|
237
|
+
return int(self._authorized_draft(principal, draft_id, permission)["account_id"])
|
|
238
|
+
|
|
239
|
+
def _view(self, row: sqlite3.Row, principal: Principal) -> PolicyDraftView:
|
|
240
|
+
account_id = int(row["account_id"])
|
|
241
|
+
target = self._target_of(row)
|
|
242
|
+
floors, defaults = parse_document(str(row["document"]))
|
|
243
|
+
current = self._policies.current(account_id, target)
|
|
244
|
+
state = str(row["state"])
|
|
245
|
+
published_version = row["published_version"]
|
|
246
|
+
if state == "published" and published_version is not None:
|
|
247
|
+
# What this draft changed when it was published, not against today's policy.
|
|
248
|
+
before = (
|
|
249
|
+
self._policies.version(account_id, int(published_version) - 1, target)
|
|
250
|
+
if int(published_version) > 1
|
|
251
|
+
else None
|
|
252
|
+
)
|
|
253
|
+
old_version, new_version = int(published_version) - 1, int(published_version)
|
|
254
|
+
old_floors = before.floors if before else {}
|
|
255
|
+
old_defaults = before.defaults if before else {}
|
|
256
|
+
else:
|
|
257
|
+
old_version, new_version = current.version, current.version + 1
|
|
258
|
+
old_floors, old_defaults = current.floors, current.defaults
|
|
259
|
+
changes = policy_changes(old_floors, floors, old_defaults, defaults)
|
|
260
|
+
diff = policy_diff(old_version, old_floors, new_version, floors, old_defaults, defaults)
|
|
261
|
+
settings = load_settings(self._store, account_id).settings
|
|
262
|
+
approvals = tuple(
|
|
263
|
+
PolicyApprovalView(
|
|
264
|
+
id=a["approval_id"],
|
|
265
|
+
status=a["status"],
|
|
266
|
+
requested_by=a["requested_by_login"],
|
|
267
|
+
requested_at=req_dt(a["requested_at"]),
|
|
268
|
+
decided_by=a["decided_by_login"],
|
|
269
|
+
decided_at=dt(a["decided_at"]),
|
|
270
|
+
reason=a["reason"],
|
|
271
|
+
)
|
|
272
|
+
for a in self._store.query(
|
|
273
|
+
"SELECT * FROM policy_approvals WHERE draft_id = ? ORDER BY requested_at DESC",
|
|
274
|
+
(row["draft_id"],),
|
|
275
|
+
)
|
|
276
|
+
)
|
|
277
|
+
author = row["created_by_id"] in (principal.user_id,) or row["submitted_by_id"] in (
|
|
278
|
+
principal.user_id,
|
|
279
|
+
)
|
|
280
|
+
can_approve = (
|
|
281
|
+
state == "pending_approval"
|
|
282
|
+
and principal.can(Permission.POLICIES_APPROVE, account_id)
|
|
283
|
+
and not (settings.require_separate_approver and author)
|
|
284
|
+
)
|
|
285
|
+
return PolicyDraftView(
|
|
286
|
+
id=row["draft_id"],
|
|
287
|
+
organization_id=account_id,
|
|
288
|
+
target=PolicyTargetView(
|
|
289
|
+
type=target.type.value,
|
|
290
|
+
id=target.id,
|
|
291
|
+
label=self._label(account_id, target),
|
|
292
|
+
),
|
|
293
|
+
title=row["title"],
|
|
294
|
+
reason=row["reason"],
|
|
295
|
+
state=state,
|
|
296
|
+
revision=int(row["revision"]),
|
|
297
|
+
base_version=int(row["base_version"]),
|
|
298
|
+
current_version=current.version,
|
|
299
|
+
floors=floors,
|
|
300
|
+
defaults=defaults,
|
|
301
|
+
changes=tuple(changes),
|
|
302
|
+
diff=diff,
|
|
303
|
+
weakening=any(c.weakening for c in changes),
|
|
304
|
+
rebase_required=state != "published" and current.version != int(row["base_version"]),
|
|
305
|
+
requires_approval=settings.require_policy_approval,
|
|
306
|
+
created_by=row["created_by_login"],
|
|
307
|
+
created_at=req_dt(row["created_at"]),
|
|
308
|
+
updated_at=req_dt(row["updated_at"]),
|
|
309
|
+
submitted_by=row["submitted_by_login"],
|
|
310
|
+
submitted_at=dt(row["submitted_at"]),
|
|
311
|
+
published_version=row["published_version"],
|
|
312
|
+
published_at=dt(row["published_at"]),
|
|
313
|
+
published_by=row["published_by_login"],
|
|
314
|
+
emergency=bool(row["emergency"]),
|
|
315
|
+
rollout_id=row["rollout_id"],
|
|
316
|
+
approvals=approvals,
|
|
317
|
+
can_edit=state in ("draft", "approved", "rejected")
|
|
318
|
+
and principal.can(Permission.POLICIES_WRITE, account_id),
|
|
319
|
+
can_submit=state == "draft" and principal.can(Permission.POLICIES_WRITE, account_id),
|
|
320
|
+
can_approve=can_approve,
|
|
321
|
+
can_publish=state in ("draft", "approved")
|
|
322
|
+
and principal.can(Permission.POLICIES_PUBLISH, account_id)
|
|
323
|
+
and (state == "approved" or not settings.require_policy_approval),
|
|
324
|
+
can_cancel=state in ("draft", "pending_approval", "approved", "rejected")
|
|
325
|
+
and principal.can(Permission.POLICIES_WRITE, account_id),
|
|
326
|
+
can_emergency_publish=state in ("draft", "pending_approval", "approved")
|
|
327
|
+
and principal.can(Permission.POLICIES_EMERGENCY, account_id),
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
# -- reads ------------------------------------------------------------ #
|
|
331
|
+
def list_drafts(
|
|
332
|
+
self, principal: Principal, account_id: int, *, state: str | None = None
|
|
333
|
+
) -> list[PolicyDraftView]:
|
|
334
|
+
require(principal, Permission.POLICIES_READ, account_id)
|
|
335
|
+
if state is not None and state not in DRAFT_STATES:
|
|
336
|
+
raise InputValidationError("unknown draft state", field="state")
|
|
337
|
+
sql = "SELECT * FROM policy_drafts WHERE account_id = ?"
|
|
338
|
+
params: list[object] = [account_id]
|
|
339
|
+
if state is not None:
|
|
340
|
+
sql += " AND state = ?"
|
|
341
|
+
params.append(state)
|
|
342
|
+
rows = self._store.query(sql + " ORDER BY updated_at DESC LIMIT 200", params)
|
|
343
|
+
views = []
|
|
344
|
+
visible = visible_repository_ids(self._store, principal, account_id)
|
|
345
|
+
for row in rows:
|
|
346
|
+
target = self._target_of(row)
|
|
347
|
+
if target.type is PolicyTargetType.REPOSITORY and int(target.id) not in visible:
|
|
348
|
+
continue
|
|
349
|
+
views.append(self._view(row, principal))
|
|
350
|
+
return views
|
|
351
|
+
|
|
352
|
+
def get(self, principal: Principal, draft_id: str) -> PolicyDraftView:
|
|
353
|
+
row = self._authorized_draft(principal, draft_id, Permission.POLICIES_READ)
|
|
354
|
+
return self._view(row, principal)
|
|
355
|
+
|
|
356
|
+
# -- writes ----------------------------------------------------------- #
|
|
357
|
+
def create(
|
|
358
|
+
self,
|
|
359
|
+
principal: Principal,
|
|
360
|
+
account_id: int,
|
|
361
|
+
*,
|
|
362
|
+
target_type: object,
|
|
363
|
+
target_id: object,
|
|
364
|
+
floors: dict[str, Action],
|
|
365
|
+
defaults: dict[str, Action],
|
|
366
|
+
title: object,
|
|
367
|
+
reason: object,
|
|
368
|
+
) -> PolicyDraftView:
|
|
369
|
+
require(principal, Permission.POLICIES_WRITE, account_id)
|
|
370
|
+
target = parse_target(target_type, target_id)
|
|
371
|
+
label = self._require_target_access(principal, account_id, target)
|
|
372
|
+
clean_title = text(title, "title", limit=MAX_NAME_CHARS) or f"{label} policy change"
|
|
373
|
+
clean_reason = text(reason, "reason", limit=MAX_REASON_CHARS)
|
|
374
|
+
document = canonical_document(floors, defaults)
|
|
375
|
+
if len(document.encode("utf-8")) > MAX_DOCUMENT_BYTES:
|
|
376
|
+
raise InputValidationError("The policy document is too large.")
|
|
377
|
+
current = self._policies.current(account_id, target)
|
|
378
|
+
now = self._now()
|
|
379
|
+
draft_id = new_id()
|
|
380
|
+
with self._store.transaction() as db:
|
|
381
|
+
open_drafts = db.execute(
|
|
382
|
+
"SELECT COUNT(*) AS n FROM policy_drafts WHERE account_id = ? AND state IN "
|
|
383
|
+
"('draft', 'pending_approval', 'approved')",
|
|
384
|
+
(account_id,),
|
|
385
|
+
).fetchone()["n"]
|
|
386
|
+
if int(open_drafts) >= MAX_OPEN_DRAFTS:
|
|
387
|
+
raise ConflictError(
|
|
388
|
+
f"There are already {MAX_OPEN_DRAFTS} open policy drafts. "
|
|
389
|
+
"Publish or cancel some before creating another."
|
|
390
|
+
)
|
|
391
|
+
db.execute(
|
|
392
|
+
"INSERT INTO policy_drafts (draft_id, account_id, target_type, target_id, "
|
|
393
|
+
"base_version, document, fingerprint, title, reason, state, created_at, "
|
|
394
|
+
"created_by_id, created_by_login, updated_at) "
|
|
395
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?)",
|
|
396
|
+
(
|
|
397
|
+
draft_id,
|
|
398
|
+
account_id,
|
|
399
|
+
target.type.value,
|
|
400
|
+
target.id,
|
|
401
|
+
current.version,
|
|
402
|
+
document,
|
|
403
|
+
sha256_hex(document.encode("utf-8")),
|
|
404
|
+
clean_title,
|
|
405
|
+
clean_reason,
|
|
406
|
+
ts(now),
|
|
407
|
+
principal.user_id,
|
|
408
|
+
principal.login,
|
|
409
|
+
ts(now),
|
|
410
|
+
),
|
|
411
|
+
)
|
|
412
|
+
stored = self._store.insert_audit_event(
|
|
413
|
+
db,
|
|
414
|
+
self._audit.build(
|
|
415
|
+
AuditEventType.POLICY_DRAFT_CREATED,
|
|
416
|
+
actor=Actor.user(principal.user_id, principal.login),
|
|
417
|
+
account_id=account_id,
|
|
418
|
+
draft=draft_id,
|
|
419
|
+
target_type=target.type.value,
|
|
420
|
+
target=label,
|
|
421
|
+
base_version=current.version,
|
|
422
|
+
),
|
|
423
|
+
)
|
|
424
|
+
self._audit.log_stored(stored)
|
|
425
|
+
return self.get(principal, draft_id)
|
|
426
|
+
|
|
427
|
+
def update(
|
|
428
|
+
self,
|
|
429
|
+
principal: Principal,
|
|
430
|
+
draft_id: str,
|
|
431
|
+
*,
|
|
432
|
+
expected_revision: object,
|
|
433
|
+
floors: dict[str, Action] | None,
|
|
434
|
+
defaults: dict[str, Action] | None,
|
|
435
|
+
title: object,
|
|
436
|
+
reason: object,
|
|
437
|
+
rebase: bool = False,
|
|
438
|
+
) -> PolicyDraftView:
|
|
439
|
+
"""Edit a draft. ``rebase`` bases it on the target's current version."""
|
|
440
|
+
row = self._authorized_draft(principal, draft_id, Permission.POLICIES_WRITE)
|
|
441
|
+
account_id = int(row["account_id"])
|
|
442
|
+
state = str(row["state"])
|
|
443
|
+
if state not in ("draft", "approved", "rejected"):
|
|
444
|
+
raise ConflictError(f"A {state} draft cannot be edited.")
|
|
445
|
+
if not isinstance(expected_revision, int) or isinstance(expected_revision, bool):
|
|
446
|
+
raise InputValidationError(
|
|
447
|
+
"expected_revision must be an integer", field="expected_revision"
|
|
448
|
+
)
|
|
449
|
+
current_floors, current_defaults = parse_document(str(row["document"]))
|
|
450
|
+
document = canonical_document(
|
|
451
|
+
current_floors if floors is None else floors,
|
|
452
|
+
current_defaults if defaults is None else defaults,
|
|
453
|
+
)
|
|
454
|
+
if len(document.encode("utf-8")) > MAX_DOCUMENT_BYTES:
|
|
455
|
+
raise InputValidationError("The policy document is too large.")
|
|
456
|
+
clean_title = text(title, "title", limit=MAX_NAME_CHARS) or row["title"]
|
|
457
|
+
clean_reason = (
|
|
458
|
+
text(reason, "reason", limit=MAX_REASON_CHARS) if reason is not None else row["reason"]
|
|
459
|
+
)
|
|
460
|
+
base_version = int(row["base_version"])
|
|
461
|
+
if rebase:
|
|
462
|
+
base_version = self._policies.current(account_id, self._target_of(row)).version
|
|
463
|
+
now = self._now()
|
|
464
|
+
with self._store.transaction() as db:
|
|
465
|
+
changed = db.execute(
|
|
466
|
+
"UPDATE policy_drafts SET document = ?, fingerprint = ?, title = ?, reason = ?, "
|
|
467
|
+
"base_version = ?, state = 'draft', revision = revision + 1, updated_at = ? "
|
|
468
|
+
"WHERE draft_id = ? AND revision = ? AND state IN ('draft', 'approved', "
|
|
469
|
+
"'rejected')",
|
|
470
|
+
(
|
|
471
|
+
document,
|
|
472
|
+
sha256_hex(document.encode("utf-8")),
|
|
473
|
+
clean_title,
|
|
474
|
+
clean_reason,
|
|
475
|
+
base_version,
|
|
476
|
+
ts(now),
|
|
477
|
+
draft_id,
|
|
478
|
+
expected_revision,
|
|
479
|
+
),
|
|
480
|
+
).rowcount
|
|
481
|
+
if changed != 1:
|
|
482
|
+
raise ConflictError("The draft was changed by someone else. Reload before editing.")
|
|
483
|
+
# An edited draft loses its approval: what was approved is no longer what would
|
|
484
|
+
# be published.
|
|
485
|
+
db.execute(
|
|
486
|
+
"UPDATE policy_approvals SET status = 'cancelled', decided_at = ?, "
|
|
487
|
+
"decided_by_login = ?, reason = 'draft edited after approval' "
|
|
488
|
+
"WHERE draft_id = ? AND status IN ('pending', 'approved')",
|
|
489
|
+
(ts(now), principal.login, draft_id),
|
|
490
|
+
)
|
|
491
|
+
stored = self._store.insert_audit_event(
|
|
492
|
+
db,
|
|
493
|
+
self._audit.build(
|
|
494
|
+
AuditEventType.POLICY_DRAFT_UPDATED,
|
|
495
|
+
actor=Actor.user(principal.user_id, principal.login),
|
|
496
|
+
account_id=account_id,
|
|
497
|
+
draft=draft_id,
|
|
498
|
+
revision=int(row["revision"]) + 1,
|
|
499
|
+
base_version=base_version,
|
|
500
|
+
rebased=base_version != int(row["base_version"]),
|
|
501
|
+
),
|
|
502
|
+
)
|
|
503
|
+
self._audit.log_stored(stored)
|
|
504
|
+
return self.get(principal, draft_id)
|
|
505
|
+
|
|
506
|
+
def submit(self, principal: Principal, draft_id: str) -> PolicyDraftView:
|
|
507
|
+
row = self._authorized_draft(principal, draft_id, Permission.POLICIES_WRITE)
|
|
508
|
+
account_id = int(row["account_id"])
|
|
509
|
+
if str(row["state"]) != "draft":
|
|
510
|
+
raise ConflictError(f"The draft is {row['state']}, not a draft.")
|
|
511
|
+
now = self._now()
|
|
512
|
+
target = self._target_of(row)
|
|
513
|
+
label = self._label(account_id, target)
|
|
514
|
+
approval_id = new_id()
|
|
515
|
+
with self._store.transaction() as db:
|
|
516
|
+
if (
|
|
517
|
+
db.execute(
|
|
518
|
+
"UPDATE policy_drafts SET state = 'pending_approval', submitted_at = ?, "
|
|
519
|
+
"submitted_by_id = ?, submitted_by_login = ?, updated_at = ? "
|
|
520
|
+
"WHERE draft_id = ? AND state = 'draft'",
|
|
521
|
+
(ts(now), principal.user_id, principal.login, ts(now), draft_id),
|
|
522
|
+
).rowcount
|
|
523
|
+
!= 1
|
|
524
|
+
):
|
|
525
|
+
raise ConflictError("The draft was changed by someone else.")
|
|
526
|
+
db.execute(
|
|
527
|
+
"INSERT INTO policy_approvals (approval_id, draft_id, account_id, fingerprint, "
|
|
528
|
+
"requested_by_id, requested_by_login, requested_at, status) "
|
|
529
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, 'pending')",
|
|
530
|
+
(
|
|
531
|
+
approval_id,
|
|
532
|
+
draft_id,
|
|
533
|
+
account_id,
|
|
534
|
+
row["fingerprint"],
|
|
535
|
+
principal.user_id,
|
|
536
|
+
principal.login,
|
|
537
|
+
ts(now),
|
|
538
|
+
),
|
|
539
|
+
)
|
|
540
|
+
stored = self._store.insert_audit_event(
|
|
541
|
+
db,
|
|
542
|
+
self._audit.build(
|
|
543
|
+
AuditEventType.POLICY_APPROVAL_REQUESTED,
|
|
544
|
+
actor=Actor.user(principal.user_id, principal.login),
|
|
545
|
+
account_id=account_id,
|
|
546
|
+
draft=draft_id,
|
|
547
|
+
approval=approval_id,
|
|
548
|
+
target=label,
|
|
549
|
+
title=str(row["title"]),
|
|
550
|
+
),
|
|
551
|
+
)
|
|
552
|
+
emit(
|
|
553
|
+
db,
|
|
554
|
+
NotificationEvent(
|
|
555
|
+
type=NotificationType.POLICY_APPROVAL_REQUESTED,
|
|
556
|
+
account_id=account_id,
|
|
557
|
+
severity=Severity.HIGH,
|
|
558
|
+
resource_type="draft",
|
|
559
|
+
resource_id=draft_id,
|
|
560
|
+
dedup_key=domain_key(
|
|
561
|
+
NotificationType.POLICY_APPROVAL_REQUESTED, account_id, approval_id
|
|
562
|
+
),
|
|
563
|
+
title=f"Policy approval requested: {row['title']}",
|
|
564
|
+
body=(
|
|
565
|
+
f"{principal.login} submitted a policy change for {label} for approval. "
|
|
566
|
+
"It is not in effect until an administrator approves and publishes it."
|
|
567
|
+
),
|
|
568
|
+
metadata={"draft": draft_id, "target": label},
|
|
569
|
+
),
|
|
570
|
+
now,
|
|
571
|
+
)
|
|
572
|
+
self._audit.log_stored(stored)
|
|
573
|
+
return self.get(principal, draft_id)
|
|
574
|
+
|
|
575
|
+
def decide(
|
|
576
|
+
self, principal: Principal, draft_id: str, *, approve: bool, reason: object
|
|
577
|
+
) -> PolicyDraftView:
|
|
578
|
+
row = self._authorized_draft(principal, draft_id, Permission.POLICIES_APPROVE)
|
|
579
|
+
account_id = int(row["account_id"])
|
|
580
|
+
if str(row["state"]) != "pending_approval":
|
|
581
|
+
raise ConflictError(f"The draft is {row['state']}, not waiting for approval.")
|
|
582
|
+
settings = load_settings(self._store, account_id).settings
|
|
583
|
+
if settings.require_separate_approver and principal.user_id in {
|
|
584
|
+
row["created_by_id"],
|
|
585
|
+
row["submitted_by_id"],
|
|
586
|
+
}:
|
|
587
|
+
raise PermissionDeniedError(
|
|
588
|
+
"Separation of duties: the author of a policy change cannot approve it."
|
|
589
|
+
)
|
|
590
|
+
note = text(reason, "reason", limit=MAX_REASON_CHARS, required=not approve)
|
|
591
|
+
now = self._now()
|
|
592
|
+
with self._store.transaction() as db:
|
|
593
|
+
approval = db.execute(
|
|
594
|
+
"SELECT approval_id, fingerprint FROM policy_approvals WHERE draft_id = ? "
|
|
595
|
+
"AND status = 'pending'",
|
|
596
|
+
(draft_id,),
|
|
597
|
+
).fetchone()
|
|
598
|
+
if approval is None:
|
|
599
|
+
raise ConflictError("There is no approval request for this draft.")
|
|
600
|
+
if approval["fingerprint"] != row["fingerprint"]:
|
|
601
|
+
raise ConflictError("The draft changed after it was submitted; submit it again.")
|
|
602
|
+
db.execute(
|
|
603
|
+
"UPDATE policy_approvals SET status = ?, decided_by_id = ?, decided_by_login = ?, "
|
|
604
|
+
"decided_at = ?, reason = ? WHERE approval_id = ? AND status = 'pending'",
|
|
605
|
+
(
|
|
606
|
+
"approved" if approve else "rejected",
|
|
607
|
+
principal.user_id,
|
|
608
|
+
principal.login,
|
|
609
|
+
ts(now),
|
|
610
|
+
note,
|
|
611
|
+
approval["approval_id"],
|
|
612
|
+
),
|
|
613
|
+
)
|
|
614
|
+
db.execute(
|
|
615
|
+
"UPDATE policy_drafts SET state = ?, updated_at = ? WHERE draft_id = ? "
|
|
616
|
+
"AND state = 'pending_approval'",
|
|
617
|
+
("approved" if approve else "rejected", ts(now), draft_id),
|
|
618
|
+
)
|
|
619
|
+
stored = self._store.insert_audit_event(
|
|
620
|
+
db,
|
|
621
|
+
self._audit.build(
|
|
622
|
+
AuditEventType.POLICY_APPROVED if approve else AuditEventType.POLICY_REJECTED,
|
|
623
|
+
actor=Actor.user(principal.user_id, principal.login),
|
|
624
|
+
account_id=account_id,
|
|
625
|
+
draft=draft_id,
|
|
626
|
+
approval=str(approval["approval_id"]),
|
|
627
|
+
requested_by=row["submitted_by_login"],
|
|
628
|
+
reason=note,
|
|
629
|
+
),
|
|
630
|
+
)
|
|
631
|
+
self._audit.log_stored(stored)
|
|
632
|
+
return self.get(principal, draft_id)
|
|
633
|
+
|
|
634
|
+
def cancel(self, principal: Principal, draft_id: str) -> PolicyDraftView:
|
|
635
|
+
row = self._authorized_draft(principal, draft_id, Permission.POLICIES_WRITE)
|
|
636
|
+
account_id = int(row["account_id"])
|
|
637
|
+
if str(row["state"]) in ("published", "cancelled"):
|
|
638
|
+
raise ConflictError(f"The draft is {row['state']}.")
|
|
639
|
+
now = self._now()
|
|
640
|
+
with self._store.transaction() as db:
|
|
641
|
+
db.execute(
|
|
642
|
+
"UPDATE policy_drafts SET state = 'cancelled', updated_at = ? WHERE draft_id = ? "
|
|
643
|
+
"AND state != 'published'",
|
|
644
|
+
(ts(now), draft_id),
|
|
645
|
+
)
|
|
646
|
+
db.execute(
|
|
647
|
+
"UPDATE policy_approvals SET status = 'cancelled', decided_at = ?, "
|
|
648
|
+
"decided_by_login = ?, reason = 'draft cancelled' WHERE draft_id = ? "
|
|
649
|
+
"AND status = 'pending'",
|
|
650
|
+
(ts(now), principal.login, draft_id),
|
|
651
|
+
)
|
|
652
|
+
stored = self._store.insert_audit_event(
|
|
653
|
+
db,
|
|
654
|
+
self._audit.build(
|
|
655
|
+
AuditEventType.POLICY_DRAFT_CANCELLED,
|
|
656
|
+
actor=Actor.user(principal.user_id, principal.login),
|
|
657
|
+
account_id=account_id,
|
|
658
|
+
draft=draft_id,
|
|
659
|
+
),
|
|
660
|
+
)
|
|
661
|
+
self._audit.log_stored(stored)
|
|
662
|
+
return self.get(principal, draft_id)
|
|
663
|
+
|
|
664
|
+
def publish(
|
|
665
|
+
self,
|
|
666
|
+
principal: Principal,
|
|
667
|
+
draft_id: str,
|
|
668
|
+
*,
|
|
669
|
+
confirm_weakening: bool,
|
|
670
|
+
emergency: bool = False,
|
|
671
|
+
reason: object = None,
|
|
672
|
+
rollout: Callable[[sqlite3.Connection, PublishedPolicy], None] | None = None,
|
|
673
|
+
) -> PolicyDraftView:
|
|
674
|
+
"""Publish a draft as an immutable version. Optionally starts a staged rollout."""
|
|
675
|
+
permission = Permission.POLICIES_EMERGENCY if emergency else Permission.POLICIES_PUBLISH
|
|
676
|
+
row = self._authorized_draft(principal, draft_id, permission)
|
|
677
|
+
account_id = int(row["account_id"])
|
|
678
|
+
state = str(row["state"])
|
|
679
|
+
if state in ("published", "cancelled", "rejected"):
|
|
680
|
+
raise ConflictError(f"A {state} draft cannot be published.")
|
|
681
|
+
settings = load_settings(self._store, account_id).settings
|
|
682
|
+
if settings.require_policy_approval and state != "approved" and not emergency:
|
|
683
|
+
raise ApprovalRequiredError(
|
|
684
|
+
"This organization requires policy changes to be approved before they are "
|
|
685
|
+
"published. Submit the draft for approval, or use an emergency publication."
|
|
686
|
+
)
|
|
687
|
+
publish_reason = text(reason, "reason", limit=MAX_REASON_CHARS, required=emergency)
|
|
688
|
+
target = self._target_of(row)
|
|
689
|
+
floors, defaults = parse_document(str(row["document"]))
|
|
690
|
+
now = self._now()
|
|
691
|
+
|
|
692
|
+
def mark_published(db: sqlite3.Connection, published: PublishedPolicy) -> None:
|
|
693
|
+
db.execute(
|
|
694
|
+
"UPDATE policy_drafts SET state = 'published', published_version = ?, "
|
|
695
|
+
"published_at = ?, published_by_login = ?, emergency = ?, updated_at = ? "
|
|
696
|
+
"WHERE draft_id = ?",
|
|
697
|
+
(
|
|
698
|
+
published.version,
|
|
699
|
+
ts(now),
|
|
700
|
+
principal.login,
|
|
701
|
+
1 if emergency else 0,
|
|
702
|
+
ts(now),
|
|
703
|
+
draft_id,
|
|
704
|
+
),
|
|
705
|
+
)
|
|
706
|
+
if rollout is not None:
|
|
707
|
+
rollout(db, published)
|
|
708
|
+
db.execute(
|
|
709
|
+
"UPDATE policy_drafts SET rollout_id = (SELECT rollout_id FROM "
|
|
710
|
+
"policy_rollouts WHERE account_id = ? AND target_type = ? AND target_id = ? "
|
|
711
|
+
"AND to_version = ?) WHERE draft_id = ?",
|
|
712
|
+
(account_id, target.type.value, target.id, published.version, draft_id),
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
self._policies.update(
|
|
716
|
+
account_id=account_id,
|
|
717
|
+
actor=Actor.user(principal.user_id, principal.login),
|
|
718
|
+
authenticated_at=principal.authenticated_at,
|
|
719
|
+
expected_version=int(row["base_version"]),
|
|
720
|
+
floors=floors,
|
|
721
|
+
defaults=defaults,
|
|
722
|
+
reason=publish_reason or row["reason"],
|
|
723
|
+
confirm_weakening=confirm_weakening,
|
|
724
|
+
target=target,
|
|
725
|
+
draft_id=draft_id,
|
|
726
|
+
emergency=emergency,
|
|
727
|
+
hooks=[mark_published],
|
|
728
|
+
)
|
|
729
|
+
return self.get(principal, draft_id)
|
|
730
|
+
|
|
731
|
+
def summarize(self, account_id: int, target: PolicyTarget, document: str) -> str:
|
|
732
|
+
current = self._policies.current(account_id, target)
|
|
733
|
+
floors, defaults = parse_document(document)
|
|
734
|
+
return change_summary(policy_changes(current.floors, floors, current.defaults, defaults))
|