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,120 @@
|
|
|
1
|
+
"""Wiring of the governance services and their background work.
|
|
2
|
+
|
|
3
|
+
Background work (run by the GitHub App's maintenance loop, every minute; each
|
|
4
|
+
task is isolated so one failure never stops the others):
|
|
5
|
+
|
|
6
|
+
========================= ==============================================================
|
|
7
|
+
Task Work
|
|
8
|
+
========================= ==============================================================
|
|
9
|
+
exception expiry ACTIVE -> EXPIRED past ``expires_at``; audit, notification,
|
|
10
|
+
invalidation
|
|
11
|
+
exception warnings one notification per configured threshold before expiry
|
|
12
|
+
propagation resolve stale / never-resolved effective policies (bounded)
|
|
13
|
+
rollout safety pause (or, when configured, roll back) rollouts over threshold
|
|
14
|
+
simulations run queued policy simulations (read-only, leased)
|
|
15
|
+
bulk operations process pending items in bounded batches (leased)
|
|
16
|
+
scan schedules start due runs, queue scans within the per-tick budget
|
|
17
|
+
metrics snapshots daily organization metric snapshot (at most hourly refresh)
|
|
18
|
+
========================= ==============================================================
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from collections.abc import Callable
|
|
22
|
+
from datetime import UTC, datetime
|
|
23
|
+
|
|
24
|
+
from commitguard.controlplane.policies import OrganizationPolicyService
|
|
25
|
+
from commitguard.controlplane.queries import DashboardQueries
|
|
26
|
+
from commitguard.github.client import GitHubClient
|
|
27
|
+
from commitguard.github.installations import InstallationService
|
|
28
|
+
from commitguard.github.storage import SqliteStateStore
|
|
29
|
+
from commitguard.github.worker import ScanGovernance
|
|
30
|
+
from commitguard.governance.bulk import BulkOperationService
|
|
31
|
+
from commitguard.governance.exceptions import PolicyExceptionService
|
|
32
|
+
from commitguard.governance.groups import RepositoryGroupService
|
|
33
|
+
from commitguard.governance.inventory import RepositoryInventory
|
|
34
|
+
from commitguard.governance.posture import SecurityPostureService
|
|
35
|
+
from commitguard.governance.resolver import GovernanceResolver, scan_governance_record
|
|
36
|
+
from commitguard.governance.rollouts import PolicyRolloutService
|
|
37
|
+
from commitguard.governance.rules import OrganizationRuleService
|
|
38
|
+
from commitguard.governance.schedules import DefaultBranchScanner, ScanScheduleService
|
|
39
|
+
from commitguard.governance.settings import OrganizationSettingsService
|
|
40
|
+
from commitguard.governance.simulation import PolicySimulationService
|
|
41
|
+
from commitguard.governance.workflow import PolicyWorkflowService
|
|
42
|
+
from commitguard.observability.logging import get_logger
|
|
43
|
+
from commitguard.services.audit import AuditService
|
|
44
|
+
|
|
45
|
+
log = get_logger(__name__)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class GovernanceServices:
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
store: SqliteStateStore,
|
|
52
|
+
audit: AuditService,
|
|
53
|
+
policies: OrganizationPolicyService,
|
|
54
|
+
*,
|
|
55
|
+
installations: InstallationService | None = None,
|
|
56
|
+
client: GitHubClient | None = None,
|
|
57
|
+
enqueue: Callable[[str], bool] | None = None,
|
|
58
|
+
now: Callable[[], datetime] = lambda: datetime.now(UTC),
|
|
59
|
+
) -> None:
|
|
60
|
+
self.store = store
|
|
61
|
+
self.policies = policies
|
|
62
|
+
self.settings = OrganizationSettingsService(store, audit, now=now)
|
|
63
|
+
self.inventory = RepositoryInventory(store, audit, now=now)
|
|
64
|
+
self.groups = RepositoryGroupService(store, audit, now=now)
|
|
65
|
+
self.exceptions = PolicyExceptionService(store, audit, now=now)
|
|
66
|
+
self.resolver = GovernanceResolver(store, policies, audit, now=now)
|
|
67
|
+
self.rules = OrganizationRuleService(store, audit, now=now)
|
|
68
|
+
self.workflow = PolicyWorkflowService(store, audit, policies, now=now)
|
|
69
|
+
self.rollouts = PolicyRolloutService(store, audit, policies, now=now)
|
|
70
|
+
self.simulations = PolicySimulationService(store, audit, self.resolver, now=now)
|
|
71
|
+
self.scanner = (
|
|
72
|
+
DefaultBranchScanner(store, installations, client, self.resolver, enqueue, now=now)
|
|
73
|
+
if installations is not None and client is not None and enqueue is not None
|
|
74
|
+
else None
|
|
75
|
+
)
|
|
76
|
+
self.schedules = ScanScheduleService(store, audit, self.scanner, now=now)
|
|
77
|
+
self.bulk = BulkOperationService(
|
|
78
|
+
store, audit, self.groups, self.inventory, self.scanner, now=now
|
|
79
|
+
)
|
|
80
|
+
self.posture = SecurityPostureService(
|
|
81
|
+
store, audit, DashboardQueries(store, now=now), now=now
|
|
82
|
+
)
|
|
83
|
+
self.settings.add_hook(self.resolver.on_settings_saved)
|
|
84
|
+
self._now = now
|
|
85
|
+
|
|
86
|
+
def scan_governance(self, installation_id: int, repository_id: int) -> ScanGovernance | None:
|
|
87
|
+
resolved = self.resolver.for_scan(installation_id, repository_id)
|
|
88
|
+
if resolved is None:
|
|
89
|
+
return None
|
|
90
|
+
rules, rules_version = self.rules.compiled(resolved.account_id)
|
|
91
|
+
return ScanGovernance(
|
|
92
|
+
inputs=resolved.inputs,
|
|
93
|
+
organization_policy_version=resolved.versions.organization_policy,
|
|
94
|
+
fingerprint=resolved.fingerprint,
|
|
95
|
+
record=lambda effective: scan_governance_record(resolved, effective),
|
|
96
|
+
rules=rules,
|
|
97
|
+
rules_version=rules_version if rules is not None else None,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def run_maintenance(self) -> dict[str, int]:
|
|
101
|
+
"""One pass of governance background work. Each task is isolated."""
|
|
102
|
+
results: dict[str, int] = {}
|
|
103
|
+
tasks: dict[str, Callable[[], object]] = {
|
|
104
|
+
"exceptions_expired": self.exceptions.expire_due,
|
|
105
|
+
"exception_warnings": self.exceptions.warn_expiring,
|
|
106
|
+
"rollouts_paused": self.rollouts.evaluate,
|
|
107
|
+
"propagation": lambda: self.resolver.propagate().get("resolved", 0),
|
|
108
|
+
"simulations": self.simulations.run_pending,
|
|
109
|
+
"bulk_items": self.bulk.run_pending,
|
|
110
|
+
"scheduled_scans": lambda: self.schedules.run_due().get("queued", 0),
|
|
111
|
+
"metric_snapshots": self.posture.snapshot_metrics,
|
|
112
|
+
}
|
|
113
|
+
for name, task in tasks.items():
|
|
114
|
+
try:
|
|
115
|
+
value = task()
|
|
116
|
+
results[name] = int(value) if isinstance(value, int) else 0
|
|
117
|
+
except Exception as exc: # noqa: BLE001 - keep other tasks running
|
|
118
|
+
log.error("governance_task_failed", task=name, error_type=type(exc).__name__)
|
|
119
|
+
results[name] = -1
|
|
120
|
+
return results
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Organization security settings: versioned, audited, and weakening needs confirmation.
|
|
2
|
+
|
|
3
|
+
====================================== =========== ==========================================
|
|
4
|
+
Setting Default Effect
|
|
5
|
+
====================================== =========== ==========================================
|
|
6
|
+
``security_baseline`` none mandatory rule requirements applied to every
|
|
7
|
+
repository of the organization (a policy
|
|
8
|
+
layer above the organization policy)
|
|
9
|
+
``require_policy_approval`` off policy changes are drafted, approved, then
|
|
10
|
+
published; direct saves are refused
|
|
11
|
+
``require_separate_approver`` on the author of a policy draft cannot approve it
|
|
12
|
+
``exception_approval_min_severity`` ``high`` exceptions for rules at or above this severity
|
|
13
|
+
(and every organization- or group-wide
|
|
14
|
+
exception) need approval by someone else
|
|
15
|
+
``exception_max_days`` 90 longest allowed exception
|
|
16
|
+
``allow_permanent_exceptions`` off permanent exceptions need this *and*
|
|
17
|
+
``exceptions:approve``
|
|
18
|
+
``exception_warning_days`` 7, 3, 1 expiry warnings (days before expiry)
|
|
19
|
+
``default_onboarding_mode`` ``enforce`` mode of newly discovered repositories
|
|
20
|
+
``auto_onboard_new_repositories`` on newly discovered repositories are onboarded
|
|
21
|
+
(else they wait in ``discovered``)
|
|
22
|
+
``archived_repositories`` ``keep`` ``keep``: stay visible, no scheduled scans;
|
|
23
|
+
``exclude``: excluded from onboarding
|
|
24
|
+
``rollout_auto_pause`` on pause a staged rollout that exceeds its
|
|
25
|
+
thresholds
|
|
26
|
+
``rollout_max_error_rate`` 0.2 share of scan errors among enrolled scans
|
|
27
|
+
``rollout_max_block_rate`` 0.5 share of blocked scans among enrolled scans
|
|
28
|
+
``rollout_min_scans`` 5 scans needed before thresholds apply
|
|
29
|
+
``rollout_auto_rollback`` off roll back (not only pause) on a breach
|
|
30
|
+
``aggregate_violation_alerts`` off e-mail/webhook violation alerts as one
|
|
31
|
+
organization digest per rule and hour
|
|
32
|
+
``timezone`` ``UTC`` scan schedules and report dates
|
|
33
|
+
====================================== =========== ==========================================
|
|
34
|
+
|
|
35
|
+
The default onboarding mode is ``enforce`` so that installing the GitHub App
|
|
36
|
+
keeps its Phase 5 meaning (the check fails on violations). Organizations that
|
|
37
|
+
want to observe first set ``monitor``.
|
|
38
|
+
|
|
39
|
+
A change that relaxes a control - turning approval off, allowing permanent
|
|
40
|
+
exceptions, lowering the baseline, onboarding in monitor mode, turning off
|
|
41
|
+
rollout safety - is a *weakening* change: it needs ``confirm``, a reason and a
|
|
42
|
+
sign-in from the last 15 minutes, and produces a critical notification.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
import json
|
|
46
|
+
import sqlite3
|
|
47
|
+
from collections.abc import Callable, Mapping
|
|
48
|
+
from dataclasses import dataclass
|
|
49
|
+
from datetime import UTC, datetime
|
|
50
|
+
from typing import Any, Literal
|
|
51
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
52
|
+
|
|
53
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
|
|
54
|
+
|
|
55
|
+
from commitguard.audit.models import Actor, AuditEventType
|
|
56
|
+
from commitguard.controlplane.access import Permission, Principal
|
|
57
|
+
from commitguard.controlplane.errors import (
|
|
58
|
+
ConfirmationRequiredError,
|
|
59
|
+
ConflictError,
|
|
60
|
+
InputValidationError,
|
|
61
|
+
ReauthenticationRequiredError,
|
|
62
|
+
)
|
|
63
|
+
from commitguard.controlplane.policies import REAUTHENTICATION_WINDOW
|
|
64
|
+
from commitguard.core.decision import Action
|
|
65
|
+
from commitguard.core.result import Severity
|
|
66
|
+
from commitguard.github.storage import SqliteStateStore
|
|
67
|
+
from commitguard.governance.common import MAX_REASON_CHARS, req_dt, require, text, ts
|
|
68
|
+
from commitguard.notifications.deduplication import domain_key
|
|
69
|
+
from commitguard.notifications.models import NotificationEvent, NotificationType
|
|
70
|
+
from commitguard.notifications.outbox import emit
|
|
71
|
+
from commitguard.policies.defaults import DEFAULT_POLICIES
|
|
72
|
+
from commitguard.policies.governance import RepositoryMode
|
|
73
|
+
from commitguard.services.audit import AuditService
|
|
74
|
+
|
|
75
|
+
type SettingsHook = Callable[[sqlite3.Connection, int, "OrganizationSettings"], None]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class OrganizationSettings(BaseModel):
|
|
79
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
80
|
+
|
|
81
|
+
security_baseline: dict[str, Action] = {}
|
|
82
|
+
require_policy_approval: bool = False
|
|
83
|
+
require_separate_approver: bool = True
|
|
84
|
+
exception_approval_min_severity: Severity = Severity.HIGH
|
|
85
|
+
exception_max_days: int = Field(default=90, ge=1, le=365)
|
|
86
|
+
allow_permanent_exceptions: bool = False
|
|
87
|
+
exception_warning_days: tuple[int, ...] = (7, 3, 1)
|
|
88
|
+
default_onboarding_mode: RepositoryMode = RepositoryMode.ENFORCE
|
|
89
|
+
auto_onboard_new_repositories: bool = True
|
|
90
|
+
archived_repositories: Literal["keep", "exclude"] = "keep"
|
|
91
|
+
rollout_auto_pause: bool = True
|
|
92
|
+
rollout_max_error_rate: float = Field(default=0.2, ge=0.0, le=1.0)
|
|
93
|
+
rollout_max_block_rate: float = Field(default=0.5, ge=0.0, le=1.0)
|
|
94
|
+
rollout_min_scans: int = Field(default=5, ge=1, le=10_000)
|
|
95
|
+
rollout_auto_rollback: bool = False
|
|
96
|
+
aggregate_violation_alerts: bool = False
|
|
97
|
+
timezone: str = "UTC"
|
|
98
|
+
|
|
99
|
+
@field_validator("security_baseline")
|
|
100
|
+
@classmethod
|
|
101
|
+
def _baseline(cls, value: dict[str, Action]) -> dict[str, Action]:
|
|
102
|
+
for rule, action in value.items():
|
|
103
|
+
if rule not in DEFAULT_POLICIES:
|
|
104
|
+
raise ValueError(f"unknown rule {rule!r} in the security baseline")
|
|
105
|
+
if action is Action.ALLOW:
|
|
106
|
+
raise ValueError("a baseline requirement must be warn or block")
|
|
107
|
+
return dict(sorted(value.items()))
|
|
108
|
+
|
|
109
|
+
@field_validator("exception_warning_days")
|
|
110
|
+
@classmethod
|
|
111
|
+
def _warnings(cls, value: tuple[int, ...]) -> tuple[int, ...]:
|
|
112
|
+
if len(value) > 5 or any(not 1 <= day <= 90 for day in value):
|
|
113
|
+
raise ValueError("at most 5 warning days, each between 1 and 90")
|
|
114
|
+
return tuple(sorted(set(value), reverse=True))
|
|
115
|
+
|
|
116
|
+
@field_validator("timezone")
|
|
117
|
+
@classmethod
|
|
118
|
+
def _timezone(cls, value: str) -> str:
|
|
119
|
+
if len(value) > 64:
|
|
120
|
+
raise ValueError("unknown time zone")
|
|
121
|
+
try:
|
|
122
|
+
ZoneInfo(value)
|
|
123
|
+
except (ZoneInfoNotFoundError, ValueError):
|
|
124
|
+
raise ValueError("unknown time zone") from None
|
|
125
|
+
return value
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
DEFAULT_SETTINGS = OrganizationSettings()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def weakening_changes(old: OrganizationSettings, new: OrganizationSettings) -> list[str]:
|
|
132
|
+
"""Human-readable descriptions of every relaxed control."""
|
|
133
|
+
changes = []
|
|
134
|
+
for rule, action in old.security_baseline.items():
|
|
135
|
+
after = new.security_baseline.get(rule)
|
|
136
|
+
if after is None or after.rank < action.rank:
|
|
137
|
+
changes.append(f"security baseline {rule}: {action.value} -> {after or 'removed'}")
|
|
138
|
+
if old.require_policy_approval and not new.require_policy_approval:
|
|
139
|
+
changes.append("policy approval no longer required")
|
|
140
|
+
if old.require_separate_approver and not new.require_separate_approver:
|
|
141
|
+
changes.append("authors may approve their own policy changes")
|
|
142
|
+
if new.exception_approval_min_severity.rank > old.exception_approval_min_severity.rank:
|
|
143
|
+
changes.append(
|
|
144
|
+
"fewer exceptions need approval "
|
|
145
|
+
f"({old.exception_approval_min_severity.value} -> "
|
|
146
|
+
f"{new.exception_approval_min_severity.value})"
|
|
147
|
+
)
|
|
148
|
+
if new.exception_max_days > old.exception_max_days:
|
|
149
|
+
changes.append(
|
|
150
|
+
f"longer exceptions allowed ({old.exception_max_days} -> {new.exception_max_days} days)"
|
|
151
|
+
)
|
|
152
|
+
if new.allow_permanent_exceptions and not old.allow_permanent_exceptions:
|
|
153
|
+
changes.append("permanent exceptions allowed")
|
|
154
|
+
if (
|
|
155
|
+
old.default_onboarding_mode is RepositoryMode.ENFORCE
|
|
156
|
+
and new.default_onboarding_mode is RepositoryMode.MONITOR
|
|
157
|
+
):
|
|
158
|
+
changes.append("new repositories onboard in monitor mode (not blocking)")
|
|
159
|
+
if old.rollout_auto_pause and not new.rollout_auto_pause:
|
|
160
|
+
changes.append("staged rollouts no longer pause automatically")
|
|
161
|
+
if new.rollout_max_error_rate > old.rollout_max_error_rate:
|
|
162
|
+
changes.append("higher rollout error threshold")
|
|
163
|
+
if new.rollout_max_block_rate > old.rollout_max_block_rate:
|
|
164
|
+
changes.append("higher rollout block threshold")
|
|
165
|
+
return changes
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass(frozen=True, slots=True)
|
|
169
|
+
class StoredSettings:
|
|
170
|
+
account_id: int
|
|
171
|
+
version: int # 0: defaults, never saved
|
|
172
|
+
settings: OrganizationSettings
|
|
173
|
+
updated_at: datetime | None
|
|
174
|
+
updated_by: str | None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class SettingsView(BaseModel):
|
|
178
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
179
|
+
|
|
180
|
+
organization_id: int
|
|
181
|
+
version: int
|
|
182
|
+
settings: OrganizationSettings
|
|
183
|
+
updated_at: datetime | None
|
|
184
|
+
updated_by: str | None
|
|
185
|
+
can_manage: bool
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def load_settings(db: sqlite3.Connection | SqliteStateStore, account_id: int) -> StoredSettings:
|
|
189
|
+
sql = "SELECT * FROM organization_settings WHERE account_id = ?"
|
|
190
|
+
if isinstance(db, sqlite3.Connection):
|
|
191
|
+
row = db.execute(sql, (int(account_id),)).fetchone()
|
|
192
|
+
else:
|
|
193
|
+
rows = db.query(sql, (int(account_id),))
|
|
194
|
+
row = rows[0] if rows else None
|
|
195
|
+
if row is None:
|
|
196
|
+
return StoredSettings(int(account_id), 0, DEFAULT_SETTINGS, None, None)
|
|
197
|
+
try:
|
|
198
|
+
settings = OrganizationSettings.model_validate_json(str(row["document"]))
|
|
199
|
+
except ValidationError:
|
|
200
|
+
# A stored document that no longer validates must not silently relax
|
|
201
|
+
# anything: fall back to defaults, which are the documented baseline.
|
|
202
|
+
settings = DEFAULT_SETTINGS
|
|
203
|
+
return StoredSettings(
|
|
204
|
+
int(account_id),
|
|
205
|
+
int(row["version"]),
|
|
206
|
+
settings,
|
|
207
|
+
req_dt(row["updated_at"]),
|
|
208
|
+
row["updated_by_login"],
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class OrganizationSettingsService:
|
|
213
|
+
def __init__(
|
|
214
|
+
self,
|
|
215
|
+
store: SqliteStateStore,
|
|
216
|
+
audit: AuditService,
|
|
217
|
+
*,
|
|
218
|
+
now: Callable[[], datetime] = lambda: datetime.now(UTC),
|
|
219
|
+
) -> None:
|
|
220
|
+
self._store = store
|
|
221
|
+
self._audit = audit
|
|
222
|
+
self._now = now
|
|
223
|
+
self._hooks: list[SettingsHook] = []
|
|
224
|
+
|
|
225
|
+
def add_hook(self, hook: SettingsHook) -> None:
|
|
226
|
+
"""Run ``hook`` inside the saving transaction (e.g. effective policy invalidation)."""
|
|
227
|
+
self._hooks.append(hook)
|
|
228
|
+
|
|
229
|
+
def get(self, account_id: int) -> StoredSettings:
|
|
230
|
+
return load_settings(self._store, account_id)
|
|
231
|
+
|
|
232
|
+
def view(self, principal: Principal, account_id: int) -> SettingsView:
|
|
233
|
+
require(principal, Permission.ORGANIZATION_READ, account_id)
|
|
234
|
+
stored = self.get(account_id)
|
|
235
|
+
return SettingsView(
|
|
236
|
+
organization_id=account_id,
|
|
237
|
+
version=stored.version,
|
|
238
|
+
settings=stored.settings,
|
|
239
|
+
updated_at=stored.updated_at,
|
|
240
|
+
updated_by=stored.updated_by,
|
|
241
|
+
can_manage=principal.can(Permission.ORGANIZATION_MANAGE, account_id),
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
def update(
|
|
245
|
+
self,
|
|
246
|
+
principal: Principal,
|
|
247
|
+
account_id: int,
|
|
248
|
+
*,
|
|
249
|
+
expected_version: object,
|
|
250
|
+
changes: object,
|
|
251
|
+
reason: object,
|
|
252
|
+
confirm: object,
|
|
253
|
+
) -> SettingsView:
|
|
254
|
+
require(principal, Permission.ORGANIZATION_MANAGE, account_id)
|
|
255
|
+
if (
|
|
256
|
+
not isinstance(expected_version, int)
|
|
257
|
+
or isinstance(expected_version, bool)
|
|
258
|
+
or expected_version < 0
|
|
259
|
+
):
|
|
260
|
+
raise InputValidationError(
|
|
261
|
+
"expected_version must be a non-negative integer", field="expected_version"
|
|
262
|
+
)
|
|
263
|
+
if not isinstance(changes, Mapping) or not changes:
|
|
264
|
+
raise InputValidationError("settings must be a non-empty object", field="settings")
|
|
265
|
+
if not isinstance(confirm, bool):
|
|
266
|
+
raise InputValidationError("confirm must be true or false", field="confirm")
|
|
267
|
+
reason_text = text(reason, "reason", limit=MAX_REASON_CHARS)
|
|
268
|
+
now = self._now()
|
|
269
|
+
current = self.get(account_id)
|
|
270
|
+
if current.version != expected_version:
|
|
271
|
+
raise ConflictError(
|
|
272
|
+
f"The settings were changed by someone else (now version {current.version}). "
|
|
273
|
+
"Reload before saving."
|
|
274
|
+
)
|
|
275
|
+
merged: dict[str, Any] = {**current.settings.model_dump(mode="json"), **dict(changes)}
|
|
276
|
+
try:
|
|
277
|
+
updated = OrganizationSettings.model_validate(merged)
|
|
278
|
+
except ValidationError as exc:
|
|
279
|
+
error = exc.errors()[0]
|
|
280
|
+
location = ".".join(str(part) for part in error.get("loc", ()))
|
|
281
|
+
raise InputValidationError(
|
|
282
|
+
f"Invalid setting {location or 'value'}: {error.get('msg', 'invalid value')}",
|
|
283
|
+
field=f"settings.{location}" if location else "settings",
|
|
284
|
+
) from None
|
|
285
|
+
changed = sorted(
|
|
286
|
+
key
|
|
287
|
+
for key in type(updated).model_fields
|
|
288
|
+
if getattr(updated, key) != getattr(current.settings, key)
|
|
289
|
+
)
|
|
290
|
+
if not changed:
|
|
291
|
+
raise InputValidationError("The settings are identical to the current version.")
|
|
292
|
+
weakening = weakening_changes(current.settings, updated)
|
|
293
|
+
if weakening:
|
|
294
|
+
if not confirm:
|
|
295
|
+
raise ConfirmationRequiredError(
|
|
296
|
+
"This change relaxes security controls and must be confirmed: "
|
|
297
|
+
+ "; ".join(weakening),
|
|
298
|
+
changes=tuple(weakening),
|
|
299
|
+
)
|
|
300
|
+
if now - principal.authenticated_at > REAUTHENTICATION_WINDOW:
|
|
301
|
+
raise ReauthenticationRequiredError()
|
|
302
|
+
if reason_text is None:
|
|
303
|
+
raise InputValidationError(
|
|
304
|
+
"A reason is required when relaxing security controls.", field="reason"
|
|
305
|
+
)
|
|
306
|
+
actor = Actor.user(principal.user_id, principal.login)
|
|
307
|
+
document = updated.model_dump_json()
|
|
308
|
+
with self._store.transaction() as db:
|
|
309
|
+
row = db.execute(
|
|
310
|
+
"SELECT version FROM organization_settings WHERE account_id = ?", (account_id,)
|
|
311
|
+
).fetchone()
|
|
312
|
+
latest = int(row["version"]) if row else 0
|
|
313
|
+
if latest != expected_version:
|
|
314
|
+
raise ConflictError(
|
|
315
|
+
f"The settings were changed by someone else (now version {latest}). "
|
|
316
|
+
"Reload before saving."
|
|
317
|
+
)
|
|
318
|
+
db.execute(
|
|
319
|
+
"INSERT INTO organization_settings (account_id, version, document, updated_at, "
|
|
320
|
+
"updated_by_id, updated_by_login) VALUES (?, ?, ?, ?, ?, ?) "
|
|
321
|
+
"ON CONFLICT (account_id) DO UPDATE SET version = excluded.version, "
|
|
322
|
+
"document = excluded.document, updated_at = excluded.updated_at, "
|
|
323
|
+
"updated_by_id = excluded.updated_by_id, "
|
|
324
|
+
"updated_by_login = excluded.updated_by_login",
|
|
325
|
+
(account_id, latest + 1, document, ts(now), actor.id, actor.login),
|
|
326
|
+
)
|
|
327
|
+
stored = self._store.insert_audit_event(
|
|
328
|
+
db,
|
|
329
|
+
self._audit.build(
|
|
330
|
+
AuditEventType.ORGANIZATION_SETTINGS_CHANGED,
|
|
331
|
+
actor=actor,
|
|
332
|
+
account_id=account_id,
|
|
333
|
+
old_version=latest,
|
|
334
|
+
new_version=latest + 1,
|
|
335
|
+
changed=",".join(changed),
|
|
336
|
+
weakening="; ".join(weakening) or None,
|
|
337
|
+
baseline=json.dumps({k: v.value for k, v in updated.security_baseline.items()}),
|
|
338
|
+
reason=reason_text,
|
|
339
|
+
),
|
|
340
|
+
)
|
|
341
|
+
emit(
|
|
342
|
+
db,
|
|
343
|
+
NotificationEvent(
|
|
344
|
+
type=NotificationType.ORGANIZATION_SETTINGS_CHANGED,
|
|
345
|
+
account_id=account_id,
|
|
346
|
+
severity=Severity.CRITICAL if weakening else Severity.MEDIUM,
|
|
347
|
+
resource_type="organization",
|
|
348
|
+
resource_id=str(account_id),
|
|
349
|
+
dedup_key=domain_key(
|
|
350
|
+
NotificationType.ORGANIZATION_SETTINGS_CHANGED, account_id, latest + 1
|
|
351
|
+
),
|
|
352
|
+
title=f"Organization security settings changed (v{latest + 1})",
|
|
353
|
+
body=(
|
|
354
|
+
f"{actor.login} changed: {', '.join(changed)}."
|
|
355
|
+
+ (f" Relaxed controls: {'; '.join(weakening)}." if weakening else "")
|
|
356
|
+
+ (f" Reason: {reason_text}" if reason_text else "")
|
|
357
|
+
),
|
|
358
|
+
metadata={"new_version": latest + 1, "weakening": bool(weakening)},
|
|
359
|
+
),
|
|
360
|
+
now,
|
|
361
|
+
)
|
|
362
|
+
for hook in self._hooks:
|
|
363
|
+
hook(db, account_id, updated)
|
|
364
|
+
self._audit.log_stored(stored)
|
|
365
|
+
return self.view(principal, account_id)
|