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,861 @@
|
|
|
1
|
+
"""Policy exceptions: scoped, time-limited, approved, and kept as history.
|
|
2
|
+
|
|
3
|
+
An exception lowers the action of **one rule** within **one explicit scope** -
|
|
4
|
+
a repository, a repository group, or the whole organization - to ``warn`` or
|
|
5
|
+
``allow`` until it expires. It is the only way below a mandatory requirement,
|
|
6
|
+
which is why it is bounded on every side:
|
|
7
|
+
|
|
8
|
+
* **Scope is explicit.** A repository exception names a repository of the same
|
|
9
|
+
organization that the requester can see; a group exception names a group of
|
|
10
|
+
the organization. An exception for one repository can never apply to another.
|
|
11
|
+
* **Exceptions expire.** ``expires_at`` is required and at most
|
|
12
|
+
``exception_max_days`` ahead. A permanent exception needs the organization
|
|
13
|
+
setting ``allow_permanent_exceptions``, ``exceptions:approve`` for the
|
|
14
|
+
requester, a justification, *and* approval by someone else.
|
|
15
|
+
* **Approval.** An exception needs approval when the rule's severity is at or
|
|
16
|
+
above ``exception_approval_min_severity`` (``high`` by default: every bundled
|
|
17
|
+
AI attribution rule), when it covers a group or the organization, or when it is
|
|
18
|
+
permanent. The approver needs ``exceptions:approve`` and cannot be the
|
|
19
|
+
requester. Otherwise the exception is active immediately.
|
|
20
|
+
* **Lifecycle.** ``requested`` -> ``active`` -> ``expired`` / ``revoked``;
|
|
21
|
+
``requested`` -> ``rejected`` / ``cancelled``. Rows are never deleted (a
|
|
22
|
+
database trigger refuses it); at most one open (requested or active) exception
|
|
23
|
+
exists per rule and scope.
|
|
24
|
+
|
|
25
|
+
Every transition is audited, invalidates the effective policy of exactly the
|
|
26
|
+
repositories in scope (in the same transaction), and notifies through the
|
|
27
|
+
notification outbox: approval requests, approvals, expiry warnings (once per
|
|
28
|
+
configured threshold) and ends (expired or revoked). The maintenance loop runs
|
|
29
|
+
:meth:`PolicyExceptionService.expire_due` and :meth:`warn_expiring`.
|
|
30
|
+
|
|
31
|
+
What an exception never does: remove detection (findings are still recorded,
|
|
32
|
+
with the exception that lowered them), change historical scan results, or
|
|
33
|
+
resolve violations.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import json
|
|
37
|
+
import sqlite3
|
|
38
|
+
from collections.abc import Callable, Sequence
|
|
39
|
+
from datetime import UTC, datetime, timedelta
|
|
40
|
+
|
|
41
|
+
from pydantic import BaseModel, ConfigDict
|
|
42
|
+
|
|
43
|
+
from commitguard.audit.models import SYSTEM_ACTOR, Actor, AuditEvent, AuditEventType
|
|
44
|
+
from commitguard.controlplane.access import Permission, Principal
|
|
45
|
+
from commitguard.controlplane.errors import (
|
|
46
|
+
ConflictError,
|
|
47
|
+
InputValidationError,
|
|
48
|
+
NotFoundError,
|
|
49
|
+
PermissionDeniedError,
|
|
50
|
+
)
|
|
51
|
+
from commitguard.controlplane.pagination import parse_timestamp
|
|
52
|
+
from commitguard.controlplane.rules import CATALOG_BY_ID
|
|
53
|
+
from commitguard.core.decision import Action
|
|
54
|
+
from commitguard.core.result import Severity
|
|
55
|
+
from commitguard.github.storage import SqliteStateStore
|
|
56
|
+
from commitguard.governance.cache import invalidate_scope
|
|
57
|
+
from commitguard.governance.common import (
|
|
58
|
+
MAX_REASON_CHARS,
|
|
59
|
+
account_repositories,
|
|
60
|
+
dt,
|
|
61
|
+
is_hex_id,
|
|
62
|
+
json_list,
|
|
63
|
+
new_id,
|
|
64
|
+
req_dt,
|
|
65
|
+
require,
|
|
66
|
+
text,
|
|
67
|
+
ts,
|
|
68
|
+
visible_repository_ids,
|
|
69
|
+
)
|
|
70
|
+
from commitguard.governance.settings import load_settings
|
|
71
|
+
from commitguard.notifications.deduplication import domain_key
|
|
72
|
+
from commitguard.notifications.models import NotificationEvent, NotificationType
|
|
73
|
+
from commitguard.notifications.outbox import emit
|
|
74
|
+
from commitguard.policies.defaults import DEFAULT_POLICIES
|
|
75
|
+
from commitguard.policies.governance import ExceptionGrant, ExceptionScope
|
|
76
|
+
from commitguard.services.audit import AuditService
|
|
77
|
+
|
|
78
|
+
EXPIRY_BATCH = 200
|
|
79
|
+
OPEN_STATUSES = ("requested", "active")
|
|
80
|
+
STATUSES = ("requested", "active", "rejected", "cancelled", "revoked", "expired")
|
|
81
|
+
EXPIRING_SOON = timedelta(days=7)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ExceptionScopeView(BaseModel):
|
|
85
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
86
|
+
|
|
87
|
+
type: ExceptionScope
|
|
88
|
+
id: str
|
|
89
|
+
label: str
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class PolicyExceptionView(BaseModel):
|
|
93
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
94
|
+
|
|
95
|
+
id: str
|
|
96
|
+
organization_id: int
|
|
97
|
+
rule_id: str
|
|
98
|
+
rule_name: str
|
|
99
|
+
severity: Severity
|
|
100
|
+
scope: ExceptionScopeView
|
|
101
|
+
action: Action
|
|
102
|
+
reason: str
|
|
103
|
+
status: str
|
|
104
|
+
requires_approval: bool
|
|
105
|
+
permanent: bool
|
|
106
|
+
expires_at: datetime | None
|
|
107
|
+
expiring_soon: bool
|
|
108
|
+
requested_at: datetime
|
|
109
|
+
requested_by: str | None
|
|
110
|
+
decided_at: datetime | None
|
|
111
|
+
decided_by: str | None
|
|
112
|
+
decision_note: str | None
|
|
113
|
+
activated_at: datetime | None
|
|
114
|
+
revoked_at: datetime | None
|
|
115
|
+
revoked_by: str | None
|
|
116
|
+
revoke_reason: str | None
|
|
117
|
+
expired_at: datetime | None
|
|
118
|
+
can_approve: bool
|
|
119
|
+
can_revoke: bool
|
|
120
|
+
can_cancel: bool
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def rule_severity(rule_id: str) -> Severity:
|
|
124
|
+
entry = CATALOG_BY_ID.get(rule_id)
|
|
125
|
+
return entry.severity if entry else Severity.HIGH
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def scope_label(
|
|
129
|
+
db: sqlite3.Connection | SqliteStateStore, account_id: int, scope: str, scope_id: str
|
|
130
|
+
) -> str | None:
|
|
131
|
+
if scope == "organization":
|
|
132
|
+
return "Organization"
|
|
133
|
+
if scope == "group":
|
|
134
|
+
sql = "SELECT name FROM repository_groups WHERE group_id = ? AND account_id = ?"
|
|
135
|
+
params: tuple[object, ...] = (scope_id, int(account_id))
|
|
136
|
+
if isinstance(db, sqlite3.Connection):
|
|
137
|
+
row = db.execute(sql, params).fetchone()
|
|
138
|
+
else:
|
|
139
|
+
rows = db.query(sql, params)
|
|
140
|
+
row = rows[0] if rows else None
|
|
141
|
+
return f"Group {row['name']}" if row else None
|
|
142
|
+
if not scope_id.isdigit():
|
|
143
|
+
return None
|
|
144
|
+
repositories = account_repositories(db, account_id)
|
|
145
|
+
repository = repositories.get(int(scope_id))
|
|
146
|
+
return repository.full_name if repository else None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def active_grants(
|
|
150
|
+
db: sqlite3.Connection | SqliteStateStore,
|
|
151
|
+
account_id: int,
|
|
152
|
+
repository_id: int,
|
|
153
|
+
group_ids: Sequence[str],
|
|
154
|
+
now: datetime,
|
|
155
|
+
) -> list[ExceptionGrant]:
|
|
156
|
+
"""Active, unexpired exceptions that apply to one repository."""
|
|
157
|
+
sql = (
|
|
158
|
+
"SELECT e.exception_id, e.rule_id, e.action, e.scope_type, e.scope_id, e.expires_at, "
|
|
159
|
+
"e.permanent, (SELECT name FROM repository_groups g WHERE g.group_id = e.scope_id) "
|
|
160
|
+
"AS group_name FROM policy_exceptions e WHERE e.account_id = ? AND e.status = 'active' "
|
|
161
|
+
"AND (e.permanent = 1 OR e.expires_at > ?) AND (e.scope_type = 'organization' OR "
|
|
162
|
+
"(e.scope_type = 'repository' AND e.scope_id = ?) OR (e.scope_type = 'group' AND "
|
|
163
|
+
"e.scope_id IN (SELECT value FROM json_each(?)))) ORDER BY e.exception_id LIMIT 256"
|
|
164
|
+
)
|
|
165
|
+
params = (int(account_id), ts(now), str(int(repository_id)), json.dumps(list(group_ids)))
|
|
166
|
+
if isinstance(db, sqlite3.Connection):
|
|
167
|
+
rows = db.execute(sql, params).fetchall()
|
|
168
|
+
else:
|
|
169
|
+
rows = db.query(sql, params)
|
|
170
|
+
grants = []
|
|
171
|
+
for row in rows:
|
|
172
|
+
scope = ExceptionScope(row["scope_type"])
|
|
173
|
+
label = {
|
|
174
|
+
ExceptionScope.ORGANIZATION: "organization",
|
|
175
|
+
ExceptionScope.GROUP: f"group {row['group_name'] or row['scope_id']}",
|
|
176
|
+
ExceptionScope.REPOSITORY: "repository",
|
|
177
|
+
}[scope]
|
|
178
|
+
grants.append(
|
|
179
|
+
ExceptionGrant(
|
|
180
|
+
exception_id=row["exception_id"],
|
|
181
|
+
rule_id=row["rule_id"],
|
|
182
|
+
action=Action(row["action"]),
|
|
183
|
+
scope=scope,
|
|
184
|
+
scope_label=label,
|
|
185
|
+
expires_at=None if row["permanent"] else dt(row["expires_at"]),
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
return grants
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class PolicyExceptionService:
|
|
192
|
+
def __init__(
|
|
193
|
+
self,
|
|
194
|
+
store: SqliteStateStore,
|
|
195
|
+
audit: AuditService,
|
|
196
|
+
*,
|
|
197
|
+
now: Callable[[], datetime] = lambda: datetime.now(UTC),
|
|
198
|
+
) -> None:
|
|
199
|
+
self._store = store
|
|
200
|
+
self._audit = audit
|
|
201
|
+
self._now = now
|
|
202
|
+
|
|
203
|
+
# -- views ------------------------------------------------------------ #
|
|
204
|
+
def _view(self, row: sqlite3.Row, principal: Principal | None) -> PolicyExceptionView:
|
|
205
|
+
account_id = int(row["account_id"])
|
|
206
|
+
now = self._now()
|
|
207
|
+
expires = dt(row["expires_at"])
|
|
208
|
+
status = str(row["status"])
|
|
209
|
+
label = scope_label(self._store, account_id, row["scope_type"], row["scope_id"])
|
|
210
|
+
requester = row["requested_by_id"]
|
|
211
|
+
can_approve = bool(
|
|
212
|
+
principal is not None
|
|
213
|
+
and status == "requested"
|
|
214
|
+
and principal.can(Permission.EXCEPTIONS_APPROVE, account_id)
|
|
215
|
+
and requester != principal.user_id
|
|
216
|
+
)
|
|
217
|
+
return PolicyExceptionView(
|
|
218
|
+
id=row["exception_id"],
|
|
219
|
+
organization_id=account_id,
|
|
220
|
+
rule_id=row["rule_id"],
|
|
221
|
+
rule_name=CATALOG_BY_ID[row["rule_id"]].name
|
|
222
|
+
if row["rule_id"] in CATALOG_BY_ID
|
|
223
|
+
else row["rule_id"],
|
|
224
|
+
severity=Severity(row["severity"]),
|
|
225
|
+
scope=ExceptionScopeView(
|
|
226
|
+
type=ExceptionScope(row["scope_type"]),
|
|
227
|
+
id=row["scope_id"],
|
|
228
|
+
label=label or "(removed)",
|
|
229
|
+
),
|
|
230
|
+
action=Action(row["action"]),
|
|
231
|
+
reason=row["reason"],
|
|
232
|
+
status=status,
|
|
233
|
+
requires_approval=bool(row["requires_approval"]),
|
|
234
|
+
permanent=bool(row["permanent"]),
|
|
235
|
+
expires_at=expires,
|
|
236
|
+
expiring_soon=bool(
|
|
237
|
+
status == "active" and expires is not None and expires - now <= EXPIRING_SOON
|
|
238
|
+
),
|
|
239
|
+
requested_at=req_dt(row["requested_at"]),
|
|
240
|
+
requested_by=row["requested_by_login"],
|
|
241
|
+
decided_at=dt(row["decided_at"]),
|
|
242
|
+
decided_by=row["decided_by_login"],
|
|
243
|
+
decision_note=row["decision_note"],
|
|
244
|
+
activated_at=dt(row["activated_at"]),
|
|
245
|
+
revoked_at=dt(row["revoked_at"]),
|
|
246
|
+
revoked_by=row["revoked_by_login"],
|
|
247
|
+
revoke_reason=row["revoke_reason"],
|
|
248
|
+
expired_at=dt(row["expired_at"]),
|
|
249
|
+
can_approve=can_approve,
|
|
250
|
+
can_revoke=bool(
|
|
251
|
+
principal is not None
|
|
252
|
+
and status == "active"
|
|
253
|
+
and principal.can(Permission.EXCEPTIONS_REVOKE, account_id)
|
|
254
|
+
),
|
|
255
|
+
can_cancel=bool(
|
|
256
|
+
principal is not None
|
|
257
|
+
and status == "requested"
|
|
258
|
+
and (
|
|
259
|
+
requester == principal.user_id
|
|
260
|
+
or principal.can(Permission.EXCEPTIONS_REVOKE, account_id)
|
|
261
|
+
)
|
|
262
|
+
),
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
def _visible(self, principal: Principal, row: sqlite3.Row, visible: set[int]) -> bool:
|
|
266
|
+
if row["scope_type"] != "repository":
|
|
267
|
+
return True
|
|
268
|
+
return row["scope_id"].isdigit() and int(row["scope_id"]) in visible
|
|
269
|
+
|
|
270
|
+
def list(
|
|
271
|
+
self,
|
|
272
|
+
principal: Principal,
|
|
273
|
+
account_id: int,
|
|
274
|
+
*,
|
|
275
|
+
status: str | None = None,
|
|
276
|
+
rule_id: str | None = None,
|
|
277
|
+
repository_id: int | None = None,
|
|
278
|
+
group_id: str | None = None,
|
|
279
|
+
limit: int = 100,
|
|
280
|
+
offset: int = 0,
|
|
281
|
+
) -> tuple[list[PolicyExceptionView], bool]:
|
|
282
|
+
require(principal, Permission.EXCEPTIONS_READ, account_id)
|
|
283
|
+
if status is not None and status not in STATUSES:
|
|
284
|
+
raise InputValidationError("unknown exception status", field="status")
|
|
285
|
+
if rule_id is not None and rule_id not in DEFAULT_POLICIES:
|
|
286
|
+
raise InputValidationError("unknown rule", field="rule")
|
|
287
|
+
if group_id is not None and not is_hex_id(group_id):
|
|
288
|
+
raise InputValidationError("group must be a group ID", field="group")
|
|
289
|
+
repository_scope = str(repository_id) if repository_id is not None else None
|
|
290
|
+
rows = self._store.query(
|
|
291
|
+
"SELECT * FROM policy_exceptions WHERE account_id = ? "
|
|
292
|
+
"AND (? IS NULL OR status = ?) AND (? IS NULL OR rule_id = ?) "
|
|
293
|
+
"AND (? IS NULL OR (scope_type = 'repository' AND scope_id = ?)) "
|
|
294
|
+
"AND (? IS NULL OR (scope_type = 'group' AND scope_id = ?)) "
|
|
295
|
+
"ORDER BY CASE status WHEN 'requested' THEN 0 WHEN 'active' THEN 1 ELSE 2 END, "
|
|
296
|
+
"requested_at DESC LIMIT 5000",
|
|
297
|
+
(
|
|
298
|
+
account_id,
|
|
299
|
+
status,
|
|
300
|
+
status,
|
|
301
|
+
rule_id,
|
|
302
|
+
rule_id,
|
|
303
|
+
repository_scope,
|
|
304
|
+
repository_scope,
|
|
305
|
+
group_id,
|
|
306
|
+
group_id,
|
|
307
|
+
),
|
|
308
|
+
)
|
|
309
|
+
visible = visible_repository_ids(self._store, principal, account_id)
|
|
310
|
+
shown = [r for r in rows if self._visible(principal, r, visible)]
|
|
311
|
+
page = shown[offset : offset + limit]
|
|
312
|
+
return [self._view(r, principal) for r in page], len(shown) > offset + limit
|
|
313
|
+
|
|
314
|
+
def _row(self, exception_id: str) -> sqlite3.Row:
|
|
315
|
+
if not is_hex_id(exception_id):
|
|
316
|
+
raise NotFoundError()
|
|
317
|
+
rows = self._store.query(
|
|
318
|
+
"SELECT * FROM policy_exceptions WHERE exception_id = ?", (exception_id,)
|
|
319
|
+
)
|
|
320
|
+
if not rows:
|
|
321
|
+
raise NotFoundError()
|
|
322
|
+
return rows[0]
|
|
323
|
+
|
|
324
|
+
def _authorized_row(
|
|
325
|
+
self, principal: Principal, exception_id: str, permission: Permission
|
|
326
|
+
) -> sqlite3.Row:
|
|
327
|
+
row = self._row(exception_id)
|
|
328
|
+
account_id = int(row["account_id"])
|
|
329
|
+
require(principal, Permission.EXCEPTIONS_READ, account_id)
|
|
330
|
+
visible = visible_repository_ids(self._store, principal, account_id)
|
|
331
|
+
if not self._visible(principal, row, visible):
|
|
332
|
+
raise NotFoundError()
|
|
333
|
+
if not principal.can(permission, account_id):
|
|
334
|
+
raise PermissionDeniedError()
|
|
335
|
+
return row
|
|
336
|
+
|
|
337
|
+
def get(self, principal: Principal, exception_id: str) -> PolicyExceptionView:
|
|
338
|
+
row = self._authorized_row(principal, exception_id, Permission.EXCEPTIONS_READ)
|
|
339
|
+
return self._view(row, principal)
|
|
340
|
+
|
|
341
|
+
# -- requests --------------------------------------------------------- #
|
|
342
|
+
def request(
|
|
343
|
+
self,
|
|
344
|
+
principal: Principal,
|
|
345
|
+
account_id: int,
|
|
346
|
+
*,
|
|
347
|
+
rule_id: object,
|
|
348
|
+
scope_type: object,
|
|
349
|
+
scope_id: object,
|
|
350
|
+
action: object,
|
|
351
|
+
reason: object,
|
|
352
|
+
expires_at: object,
|
|
353
|
+
permanent: object = False,
|
|
354
|
+
) -> PolicyExceptionView:
|
|
355
|
+
require(principal, Permission.EXCEPTIONS_CREATE, account_id)
|
|
356
|
+
now = self._now()
|
|
357
|
+
if not isinstance(rule_id, str) or rule_id not in DEFAULT_POLICIES:
|
|
358
|
+
raise InputValidationError("rule_id must be a known rule", field="rule_id")
|
|
359
|
+
if not isinstance(scope_type, str) or scope_type not in {s.value for s in ExceptionScope}:
|
|
360
|
+
raise InputValidationError(
|
|
361
|
+
"scope_type must be organization, group or repository", field="scope_type"
|
|
362
|
+
)
|
|
363
|
+
if action not in ("warn", "allow"):
|
|
364
|
+
raise InputValidationError(
|
|
365
|
+
"action must be warn or allow (an exception lowers enforcement)", field="action"
|
|
366
|
+
)
|
|
367
|
+
reason_text = text(reason, "reason", limit=MAX_REASON_CHARS, required=True)
|
|
368
|
+
if not isinstance(permanent, bool):
|
|
369
|
+
raise InputValidationError("permanent must be true or false", field="permanent")
|
|
370
|
+
settings = load_settings(self._store, account_id).settings
|
|
371
|
+
scope = ExceptionScope(scope_type)
|
|
372
|
+
clean_scope_id = self._scope_id(principal, account_id, scope, scope_id)
|
|
373
|
+
|
|
374
|
+
expires: datetime | None = None
|
|
375
|
+
if permanent:
|
|
376
|
+
if not settings.allow_permanent_exceptions:
|
|
377
|
+
raise PermissionDeniedError(
|
|
378
|
+
"This organization does not allow permanent exceptions."
|
|
379
|
+
)
|
|
380
|
+
if not principal.can(Permission.EXCEPTIONS_APPROVE, account_id):
|
|
381
|
+
raise PermissionDeniedError(
|
|
382
|
+
"Only administrators who can approve exceptions may request a permanent one."
|
|
383
|
+
)
|
|
384
|
+
if expires_at is not None:
|
|
385
|
+
raise InputValidationError(
|
|
386
|
+
"A permanent exception has no expiry.", field="expires_at"
|
|
387
|
+
)
|
|
388
|
+
else:
|
|
389
|
+
if not isinstance(expires_at, str):
|
|
390
|
+
raise InputValidationError(
|
|
391
|
+
"expires_at is required: exceptions are temporary", field="expires_at"
|
|
392
|
+
)
|
|
393
|
+
expires = parse_timestamp(expires_at, "expires_at")
|
|
394
|
+
if expires is None or expires <= now + timedelta(minutes=5):
|
|
395
|
+
raise InputValidationError("expires_at must be in the future", field="expires_at")
|
|
396
|
+
if expires > now + timedelta(days=settings.exception_max_days):
|
|
397
|
+
raise InputValidationError(
|
|
398
|
+
f"An exception may last at most {settings.exception_max_days} days.",
|
|
399
|
+
field="expires_at",
|
|
400
|
+
)
|
|
401
|
+
severity = rule_severity(rule_id)
|
|
402
|
+
requires_approval = (
|
|
403
|
+
severity.rank >= settings.exception_approval_min_severity.rank
|
|
404
|
+
or scope is not ExceptionScope.REPOSITORY
|
|
405
|
+
or permanent
|
|
406
|
+
)
|
|
407
|
+
status = "requested" if requires_approval else "active"
|
|
408
|
+
exception_id = new_id()
|
|
409
|
+
actor = Actor.user(principal.user_id, principal.login)
|
|
410
|
+
stored: list[AuditEvent] = []
|
|
411
|
+
with self._store.transaction() as db:
|
|
412
|
+
if db.execute(
|
|
413
|
+
"SELECT 1 FROM policy_exceptions WHERE account_id = ? AND rule_id = ? "
|
|
414
|
+
"AND scope_type = ? AND scope_id = ? AND status IN ('requested', 'active')",
|
|
415
|
+
(account_id, rule_id, scope.value, clean_scope_id),
|
|
416
|
+
).fetchone():
|
|
417
|
+
raise ConflictError(
|
|
418
|
+
"An open exception for this rule and scope already exists. Revoke or cancel "
|
|
419
|
+
"it before requesting another."
|
|
420
|
+
)
|
|
421
|
+
db.execute(
|
|
422
|
+
"INSERT INTO policy_exceptions (exception_id, account_id, rule_id, scope_type, "
|
|
423
|
+
"scope_id, action, severity, reason, status, requires_approval, permanent, "
|
|
424
|
+
"expires_at, requested_at, requested_by_id, requested_by_login, activated_at, "
|
|
425
|
+
"updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
426
|
+
(
|
|
427
|
+
exception_id,
|
|
428
|
+
account_id,
|
|
429
|
+
rule_id,
|
|
430
|
+
scope.value,
|
|
431
|
+
clean_scope_id,
|
|
432
|
+
action,
|
|
433
|
+
severity.value,
|
|
434
|
+
reason_text,
|
|
435
|
+
status,
|
|
436
|
+
1 if requires_approval else 0,
|
|
437
|
+
1 if permanent else 0,
|
|
438
|
+
ts(expires) if expires else None,
|
|
439
|
+
ts(now),
|
|
440
|
+
principal.user_id,
|
|
441
|
+
principal.login,
|
|
442
|
+
None if requires_approval else ts(now),
|
|
443
|
+
ts(now),
|
|
444
|
+
),
|
|
445
|
+
)
|
|
446
|
+
label = scope_label(db, account_id, scope.value, clean_scope_id) or clean_scope_id
|
|
447
|
+
repository = (
|
|
448
|
+
account_repositories(db, account_id).get(int(clean_scope_id))
|
|
449
|
+
if scope is ExceptionScope.REPOSITORY
|
|
450
|
+
else None
|
|
451
|
+
)
|
|
452
|
+
stored.append(
|
|
453
|
+
self._store.insert_audit_event(
|
|
454
|
+
db,
|
|
455
|
+
self._audit.build(
|
|
456
|
+
AuditEventType.EXCEPTION_REQUESTED,
|
|
457
|
+
actor=actor,
|
|
458
|
+
account_id=account_id,
|
|
459
|
+
# Repository events carry the installation too: audit visibility is
|
|
460
|
+
# scoped by (installation, repository) per session.
|
|
461
|
+
installation_id=repository.installation_id if repository else None,
|
|
462
|
+
repository_id=repository.repository_id if repository else None,
|
|
463
|
+
repository=repository.full_name if repository else None,
|
|
464
|
+
exception=exception_id,
|
|
465
|
+
rule=rule_id,
|
|
466
|
+
scope=scope.value,
|
|
467
|
+
target=label,
|
|
468
|
+
exception_action=action,
|
|
469
|
+
severity=severity.value,
|
|
470
|
+
expires_at=expires.isoformat() if expires else "permanent",
|
|
471
|
+
requires_approval=requires_approval,
|
|
472
|
+
reason=reason_text,
|
|
473
|
+
),
|
|
474
|
+
)
|
|
475
|
+
)
|
|
476
|
+
if requires_approval:
|
|
477
|
+
emit(
|
|
478
|
+
db,
|
|
479
|
+
self._notification(
|
|
480
|
+
NotificationType.EXCEPTION_REQUESTED,
|
|
481
|
+
account_id,
|
|
482
|
+
exception_id,
|
|
483
|
+
severity=severity,
|
|
484
|
+
title=f"Exception requested: {rule_id} ({label})",
|
|
485
|
+
body=(
|
|
486
|
+
f"{principal.login} requested an exception lowering {rule_id} to "
|
|
487
|
+
f"{action} for {label} until "
|
|
488
|
+
f"{expires.date().isoformat() if expires else 'revoked (permanent)'}. "
|
|
489
|
+
f"Reason: {reason_text}. It needs approval by someone else."
|
|
490
|
+
),
|
|
491
|
+
key_parts=(exception_id,),
|
|
492
|
+
),
|
|
493
|
+
now,
|
|
494
|
+
)
|
|
495
|
+
else:
|
|
496
|
+
invalidate_scope(db, account_id, scope.value, clean_scope_id, now)
|
|
497
|
+
for event in stored:
|
|
498
|
+
self._audit.log_stored(event)
|
|
499
|
+
return self._view(self._row(exception_id), principal)
|
|
500
|
+
|
|
501
|
+
def _scope_id(
|
|
502
|
+
self, principal: Principal, account_id: int, scope: ExceptionScope, raw: object
|
|
503
|
+
) -> str:
|
|
504
|
+
if scope is ExceptionScope.ORGANIZATION:
|
|
505
|
+
if raw not in (None, ""):
|
|
506
|
+
raise InputValidationError(
|
|
507
|
+
"an organization exception has no scope_id", field="scope_id"
|
|
508
|
+
)
|
|
509
|
+
return ""
|
|
510
|
+
if scope is ExceptionScope.GROUP:
|
|
511
|
+
if not is_hex_id(raw):
|
|
512
|
+
raise InputValidationError("scope_id must be a group ID", field="scope_id")
|
|
513
|
+
assert isinstance(raw, str) # noqa: S101 - checked above
|
|
514
|
+
rows = self._store.query(
|
|
515
|
+
"SELECT 1 FROM repository_groups WHERE group_id = ? AND account_id = ? "
|
|
516
|
+
"AND archived_at IS NULL",
|
|
517
|
+
(raw, account_id),
|
|
518
|
+
)
|
|
519
|
+
if not rows:
|
|
520
|
+
raise NotFoundError("The group was not found in this organization.")
|
|
521
|
+
return raw
|
|
522
|
+
if not isinstance(raw, int) or isinstance(raw, bool):
|
|
523
|
+
if isinstance(raw, str) and raw.isdigit() and len(raw) < 17:
|
|
524
|
+
raw = int(raw)
|
|
525
|
+
else:
|
|
526
|
+
raise InputValidationError("scope_id must be a repository ID", field="scope_id")
|
|
527
|
+
repository_id = int(raw)
|
|
528
|
+
visible = visible_repository_ids(self._store, principal, account_id)
|
|
529
|
+
if repository_id not in visible or repository_id not in account_repositories(
|
|
530
|
+
self._store, account_id
|
|
531
|
+
):
|
|
532
|
+
raise NotFoundError("The repository was not found in this organization.")
|
|
533
|
+
return str(repository_id)
|
|
534
|
+
|
|
535
|
+
@staticmethod
|
|
536
|
+
def _notification(
|
|
537
|
+
notification_type: NotificationType,
|
|
538
|
+
account_id: int,
|
|
539
|
+
exception_id: str,
|
|
540
|
+
*,
|
|
541
|
+
severity: Severity,
|
|
542
|
+
title: str,
|
|
543
|
+
body: str,
|
|
544
|
+
key_parts: tuple[str | int, ...],
|
|
545
|
+
) -> NotificationEvent:
|
|
546
|
+
return NotificationEvent(
|
|
547
|
+
type=notification_type,
|
|
548
|
+
account_id=account_id,
|
|
549
|
+
severity=severity,
|
|
550
|
+
resource_type="exception",
|
|
551
|
+
resource_id=exception_id,
|
|
552
|
+
dedup_key=domain_key(notification_type, account_id, *key_parts),
|
|
553
|
+
title=title,
|
|
554
|
+
body=body,
|
|
555
|
+
metadata={"exception": exception_id},
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
# -- decisions -------------------------------------------------------- #
|
|
559
|
+
def approve(self, principal: Principal, exception_id: str, note: object) -> PolicyExceptionView:
|
|
560
|
+
row = self._authorized_row(principal, exception_id, Permission.EXCEPTIONS_APPROVE)
|
|
561
|
+
account_id = int(row["account_id"])
|
|
562
|
+
if row["status"] != "requested":
|
|
563
|
+
raise ConflictError(f"The exception is {row['status']}, not waiting for approval.")
|
|
564
|
+
if row["requested_by_id"] == principal.user_id:
|
|
565
|
+
raise PermissionDeniedError("An exception must be approved by someone else.")
|
|
566
|
+
note_text = text(note, "note", limit=MAX_REASON_CHARS)
|
|
567
|
+
now = self._now()
|
|
568
|
+
expires = dt(row["expires_at"])
|
|
569
|
+
if not row["permanent"] and (expires is None or expires <= now):
|
|
570
|
+
raise ConflictError("The exception's expiry has passed; request a new one.")
|
|
571
|
+
actor = Actor.user(principal.user_id, principal.login)
|
|
572
|
+
with self._store.transaction() as db:
|
|
573
|
+
changed = db.execute(
|
|
574
|
+
"UPDATE policy_exceptions SET status = 'active', decided_at = ?, "
|
|
575
|
+
"decided_by_id = ?, "
|
|
576
|
+
"decided_by_login = ?, decision_note = ?, activated_at = ?, updated_at = ? "
|
|
577
|
+
"WHERE exception_id = ? AND status = 'requested'",
|
|
578
|
+
(
|
|
579
|
+
ts(now),
|
|
580
|
+
principal.user_id,
|
|
581
|
+
principal.login,
|
|
582
|
+
note_text,
|
|
583
|
+
ts(now),
|
|
584
|
+
ts(now),
|
|
585
|
+
exception_id,
|
|
586
|
+
),
|
|
587
|
+
).rowcount
|
|
588
|
+
if changed != 1:
|
|
589
|
+
raise ConflictError("The exception was changed by someone else.")
|
|
590
|
+
label = scope_label(db, account_id, row["scope_type"], row["scope_id"]) or ""
|
|
591
|
+
invalidate_scope(db, account_id, row["scope_type"], row["scope_id"], now)
|
|
592
|
+
stored = self._store.insert_audit_event(
|
|
593
|
+
db,
|
|
594
|
+
self._audit.build(
|
|
595
|
+
AuditEventType.EXCEPTION_APPROVED,
|
|
596
|
+
actor=actor,
|
|
597
|
+
account_id=account_id,
|
|
598
|
+
exception=exception_id,
|
|
599
|
+
rule=row["rule_id"],
|
|
600
|
+
scope=row["scope_type"],
|
|
601
|
+
target=label,
|
|
602
|
+
requested_by=row["requested_by_login"],
|
|
603
|
+
expires_at=expires.isoformat() if expires else "permanent",
|
|
604
|
+
note=note_text,
|
|
605
|
+
),
|
|
606
|
+
)
|
|
607
|
+
emit(
|
|
608
|
+
db,
|
|
609
|
+
self._notification(
|
|
610
|
+
NotificationType.EXCEPTION_APPROVED,
|
|
611
|
+
account_id,
|
|
612
|
+
exception_id,
|
|
613
|
+
severity=Severity(row["severity"]),
|
|
614
|
+
title=f"Exception approved: {row['rule_id']} ({label})",
|
|
615
|
+
body=(
|
|
616
|
+
f"{principal.login} approved {row['requested_by_login']}'s exception: "
|
|
617
|
+
f"{row['rule_id']} is {row['action']} for {label} until "
|
|
618
|
+
f"{expires.date().isoformat() if expires else 'revoked (permanent)'}. "
|
|
619
|
+
f"Reason: {row['reason']}"
|
|
620
|
+
),
|
|
621
|
+
key_parts=(exception_id,),
|
|
622
|
+
),
|
|
623
|
+
now,
|
|
624
|
+
)
|
|
625
|
+
self._audit.log_stored(stored)
|
|
626
|
+
return self._view(self._row(exception_id), principal)
|
|
627
|
+
|
|
628
|
+
def reject(self, principal: Principal, exception_id: str, note: object) -> PolicyExceptionView:
|
|
629
|
+
row = self._authorized_row(principal, exception_id, Permission.EXCEPTIONS_APPROVE)
|
|
630
|
+
if row["status"] != "requested":
|
|
631
|
+
raise ConflictError(f"The exception is {row['status']}, not waiting for approval.")
|
|
632
|
+
note_text = text(note, "note", limit=MAX_REASON_CHARS, required=True)
|
|
633
|
+
return self._end(
|
|
634
|
+
principal,
|
|
635
|
+
row,
|
|
636
|
+
status="rejected",
|
|
637
|
+
from_status="requested",
|
|
638
|
+
audit_type=AuditEventType.EXCEPTION_REJECTED,
|
|
639
|
+
note=note_text,
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
def cancel(self, principal: Principal, exception_id: str) -> PolicyExceptionView:
|
|
643
|
+
row = self._authorized_row(principal, exception_id, Permission.EXCEPTIONS_READ)
|
|
644
|
+
account_id = int(row["account_id"])
|
|
645
|
+
if row["status"] != "requested":
|
|
646
|
+
raise ConflictError(
|
|
647
|
+
f"The exception is {row['status']}; only requests can be cancelled."
|
|
648
|
+
)
|
|
649
|
+
if row["requested_by_id"] != principal.user_id and not principal.can(
|
|
650
|
+
Permission.EXCEPTIONS_REVOKE, account_id
|
|
651
|
+
):
|
|
652
|
+
raise PermissionDeniedError()
|
|
653
|
+
return self._end(
|
|
654
|
+
principal,
|
|
655
|
+
row,
|
|
656
|
+
status="cancelled",
|
|
657
|
+
from_status="requested",
|
|
658
|
+
audit_type=AuditEventType.EXCEPTION_CANCELLED,
|
|
659
|
+
note=None,
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
def revoke(
|
|
663
|
+
self, principal: Principal, exception_id: str, reason: object
|
|
664
|
+
) -> PolicyExceptionView:
|
|
665
|
+
row = self._authorized_row(principal, exception_id, Permission.EXCEPTIONS_REVOKE)
|
|
666
|
+
if row["status"] != "active":
|
|
667
|
+
raise ConflictError(f"The exception is {row['status']}; only active ones are revoked.")
|
|
668
|
+
reason_text = text(reason, "reason", limit=MAX_REASON_CHARS, required=True)
|
|
669
|
+
return self._end(
|
|
670
|
+
principal,
|
|
671
|
+
row,
|
|
672
|
+
status="revoked",
|
|
673
|
+
from_status="active",
|
|
674
|
+
audit_type=AuditEventType.EXCEPTION_REVOKED,
|
|
675
|
+
note=reason_text,
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
def _end(
|
|
679
|
+
self,
|
|
680
|
+
principal: Principal,
|
|
681
|
+
row: sqlite3.Row,
|
|
682
|
+
*,
|
|
683
|
+
status: str,
|
|
684
|
+
from_status: str,
|
|
685
|
+
audit_type: AuditEventType,
|
|
686
|
+
note: str | None,
|
|
687
|
+
) -> PolicyExceptionView:
|
|
688
|
+
account_id = int(row["account_id"])
|
|
689
|
+
exception_id = str(row["exception_id"])
|
|
690
|
+
now = self._now()
|
|
691
|
+
actor = Actor.user(principal.user_id, principal.login)
|
|
692
|
+
with self._store.transaction() as db:
|
|
693
|
+
if status == "revoked":
|
|
694
|
+
sql = (
|
|
695
|
+
"UPDATE policy_exceptions SET status = 'revoked', revoked_at = ?, "
|
|
696
|
+
"revoked_by_login = ?, revoke_reason = ?, updated_at = ? "
|
|
697
|
+
"WHERE exception_id = ? AND status = ?"
|
|
698
|
+
)
|
|
699
|
+
else:
|
|
700
|
+
sql = (
|
|
701
|
+
"UPDATE policy_exceptions SET status = ?, decided_at = ?, "
|
|
702
|
+
"decided_by_login = ?, "
|
|
703
|
+
"decision_note = ?, updated_at = ? WHERE exception_id = ? AND status = ?"
|
|
704
|
+
)
|
|
705
|
+
params: tuple[object, ...] = (
|
|
706
|
+
(ts(now), principal.login, note, ts(now), exception_id, from_status)
|
|
707
|
+
if status == "revoked"
|
|
708
|
+
else (status, ts(now), principal.login, note, ts(now), exception_id, from_status)
|
|
709
|
+
)
|
|
710
|
+
if db.execute(sql, params).rowcount != 1:
|
|
711
|
+
raise ConflictError("The exception was changed by someone else.")
|
|
712
|
+
label = scope_label(db, account_id, row["scope_type"], row["scope_id"]) or ""
|
|
713
|
+
if from_status == "active":
|
|
714
|
+
invalidate_scope(db, account_id, row["scope_type"], row["scope_id"], now)
|
|
715
|
+
stored = self._store.insert_audit_event(
|
|
716
|
+
db,
|
|
717
|
+
self._audit.build(
|
|
718
|
+
audit_type,
|
|
719
|
+
actor=actor,
|
|
720
|
+
account_id=account_id,
|
|
721
|
+
exception=exception_id,
|
|
722
|
+
rule=row["rule_id"],
|
|
723
|
+
scope=row["scope_type"],
|
|
724
|
+
target=label,
|
|
725
|
+
note=note,
|
|
726
|
+
),
|
|
727
|
+
)
|
|
728
|
+
if status == "revoked":
|
|
729
|
+
emit(
|
|
730
|
+
db,
|
|
731
|
+
self._notification(
|
|
732
|
+
NotificationType.EXCEPTION_ENDED,
|
|
733
|
+
account_id,
|
|
734
|
+
exception_id,
|
|
735
|
+
severity=Severity(row["severity"]),
|
|
736
|
+
title=f"Exception revoked: {row['rule_id']} ({label})",
|
|
737
|
+
body=(
|
|
738
|
+
f"{principal.login} revoked the exception for {row['rule_id']} on "
|
|
739
|
+
f"{label}. The policy applies again. Reason: {note}"
|
|
740
|
+
),
|
|
741
|
+
key_parts=(exception_id, "ended"),
|
|
742
|
+
),
|
|
743
|
+
now,
|
|
744
|
+
)
|
|
745
|
+
self._audit.log_stored(stored)
|
|
746
|
+
return self._view(self._row(exception_id), principal)
|
|
747
|
+
|
|
748
|
+
# -- background ------------------------------------------------------- #
|
|
749
|
+
def expire_due(self) -> int:
|
|
750
|
+
"""ACTIVE -> EXPIRED for exceptions past their expiry; the policy applies again."""
|
|
751
|
+
now = self._now()
|
|
752
|
+
rows = self._store.query(
|
|
753
|
+
"SELECT * FROM policy_exceptions WHERE status = 'active' AND permanent = 0 "
|
|
754
|
+
"AND expires_at <= ? ORDER BY expires_at LIMIT ?",
|
|
755
|
+
(ts(now), EXPIRY_BATCH),
|
|
756
|
+
)
|
|
757
|
+
expired = 0
|
|
758
|
+
for row in rows:
|
|
759
|
+
account_id = int(row["account_id"])
|
|
760
|
+
with self._store.transaction() as db:
|
|
761
|
+
if (
|
|
762
|
+
db.execute(
|
|
763
|
+
"UPDATE policy_exceptions SET status = 'expired', expired_at = ?, "
|
|
764
|
+
"updated_at = ? WHERE exception_id = ? AND status = 'active'",
|
|
765
|
+
(ts(now), ts(now), row["exception_id"]),
|
|
766
|
+
).rowcount
|
|
767
|
+
!= 1
|
|
768
|
+
):
|
|
769
|
+
continue
|
|
770
|
+
label = scope_label(db, account_id, row["scope_type"], row["scope_id"]) or ""
|
|
771
|
+
invalidate_scope(db, account_id, row["scope_type"], row["scope_id"], now)
|
|
772
|
+
stored = self._store.insert_audit_event(
|
|
773
|
+
db,
|
|
774
|
+
self._audit.build(
|
|
775
|
+
AuditEventType.EXCEPTION_EXPIRED,
|
|
776
|
+
actor=SYSTEM_ACTOR,
|
|
777
|
+
account_id=account_id,
|
|
778
|
+
exception=row["exception_id"],
|
|
779
|
+
rule=row["rule_id"],
|
|
780
|
+
scope=row["scope_type"],
|
|
781
|
+
target=label,
|
|
782
|
+
expires_at=req_dt(row["expires_at"]).isoformat(),
|
|
783
|
+
),
|
|
784
|
+
)
|
|
785
|
+
emit(
|
|
786
|
+
db,
|
|
787
|
+
self._notification(
|
|
788
|
+
NotificationType.EXCEPTION_ENDED,
|
|
789
|
+
account_id,
|
|
790
|
+
row["exception_id"],
|
|
791
|
+
severity=Severity(row["severity"]),
|
|
792
|
+
title=f"Exception expired: {row['rule_id']} ({label})",
|
|
793
|
+
body=(
|
|
794
|
+
f"The exception lowering {row['rule_id']} to {row['action']} for "
|
|
795
|
+
f"{label} expired. The policy applies again to new scans."
|
|
796
|
+
),
|
|
797
|
+
key_parts=(row["exception_id"], "ended"),
|
|
798
|
+
),
|
|
799
|
+
now,
|
|
800
|
+
)
|
|
801
|
+
self._audit.log_stored(stored)
|
|
802
|
+
expired += 1
|
|
803
|
+
return expired
|
|
804
|
+
|
|
805
|
+
def warn_expiring(self) -> int:
|
|
806
|
+
"""One warning per configured threshold crossed; never repeated per polling cycle."""
|
|
807
|
+
now = self._now()
|
|
808
|
+
horizon = now + timedelta(days=90)
|
|
809
|
+
rows = self._store.query(
|
|
810
|
+
"SELECT * FROM policy_exceptions WHERE status = 'active' AND permanent = 0 "
|
|
811
|
+
"AND expires_at > ? AND expires_at <= ? ORDER BY expires_at LIMIT ?",
|
|
812
|
+
(ts(now), ts(horizon), EXPIRY_BATCH),
|
|
813
|
+
)
|
|
814
|
+
warned = 0
|
|
815
|
+
for row in rows:
|
|
816
|
+
account_id = int(row["account_id"])
|
|
817
|
+
thresholds = load_settings(self._store, account_id).settings.exception_warning_days
|
|
818
|
+
expires = req_dt(row["expires_at"])
|
|
819
|
+
sent = {int(d) for d in json_list(row["warnings_sent"]) if isinstance(d, int)}
|
|
820
|
+
crossed = [d for d in thresholds if expires - now <= timedelta(days=d)]
|
|
821
|
+
pending = [d for d in crossed if d not in sent]
|
|
822
|
+
if not pending:
|
|
823
|
+
continue
|
|
824
|
+
day = min(pending) # the most urgent threshold crossed; larger ones are implied
|
|
825
|
+
with self._store.transaction() as db:
|
|
826
|
+
db.execute(
|
|
827
|
+
"UPDATE policy_exceptions SET warnings_sent = ?, updated_at = ? "
|
|
828
|
+
"WHERE exception_id = ? AND status = 'active'",
|
|
829
|
+
(json.dumps(sorted(sent | set(crossed))), ts(now), row["exception_id"]),
|
|
830
|
+
)
|
|
831
|
+
label = scope_label(db, account_id, row["scope_type"], row["scope_id"]) or ""
|
|
832
|
+
emit(
|
|
833
|
+
db,
|
|
834
|
+
self._notification(
|
|
835
|
+
NotificationType.EXCEPTION_EXPIRING,
|
|
836
|
+
account_id,
|
|
837
|
+
row["exception_id"],
|
|
838
|
+
severity=Severity(row["severity"]),
|
|
839
|
+
title=f"Exception expires soon: {row['rule_id']} ({label})",
|
|
840
|
+
body=(
|
|
841
|
+
f"The exception lowering {row['rule_id']} to {row['action']} for "
|
|
842
|
+
f"{label} expires on {expires.isoformat(timespec='minutes')} "
|
|
843
|
+
f"(within {day} day(s)). Afterwards the policy applies again."
|
|
844
|
+
),
|
|
845
|
+
key_parts=(row["exception_id"], "expiring", day),
|
|
846
|
+
),
|
|
847
|
+
now,
|
|
848
|
+
)
|
|
849
|
+
warned += 1
|
|
850
|
+
return warned
|
|
851
|
+
|
|
852
|
+
def counts(
|
|
853
|
+
self, account_id: int, repository_id: int, group_ids: Sequence[str]
|
|
854
|
+
) -> tuple[int, int]:
|
|
855
|
+
"""(active exceptions, expiring within 7 days) that apply to a repository."""
|
|
856
|
+
now = self._now()
|
|
857
|
+
grants = active_grants(self._store, account_id, repository_id, group_ids, now)
|
|
858
|
+
soon = sum(
|
|
859
|
+
1 for g in grants if g.expires_at is not None and g.expires_at - now <= EXPIRING_SOON
|
|
860
|
+
)
|
|
861
|
+
return len(grants), soon
|