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
commitguard/api/app.py
ADDED
|
@@ -0,0 +1,1376 @@
|
|
|
1
|
+
"""The ``/api/v1`` WSGI application.
|
|
2
|
+
|
|
3
|
+
Every route declares, in the table at the bottom of :class:`DashboardApi`:
|
|
4
|
+
|
|
5
|
+
* whether it is public (only sign-in routes are);
|
|
6
|
+
* the permission whose access scope its handler receives - authentication,
|
|
7
|
+
tenant resolution and the permission check happen in :meth:`DashboardApi._dispatch`,
|
|
8
|
+
never ad hoc in handlers;
|
|
9
|
+
* a rate-limit category.
|
|
10
|
+
|
|
11
|
+
Handlers only parse input, call a control plane service and serialise its
|
|
12
|
+
result. Resource-level checks (does this scan belong to a repository the user
|
|
13
|
+
can see? may their role acknowledge violations in that organisation?) live in
|
|
14
|
+
the services, which answer "not found" for anything outside the caller's scope.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
import time
|
|
19
|
+
import uuid
|
|
20
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from datetime import UTC, datetime
|
|
23
|
+
from typing import Any
|
|
24
|
+
from urllib.parse import quote
|
|
25
|
+
from wsgiref.types import StartResponse, WSGIEnvironment
|
|
26
|
+
|
|
27
|
+
from commitguard.api.governance import GovernanceRoutes
|
|
28
|
+
from commitguard.api.http import (
|
|
29
|
+
API_SECURITY_HEADERS,
|
|
30
|
+
MAX_BODY_BYTES,
|
|
31
|
+
NOT_FOUND,
|
|
32
|
+
REASONS,
|
|
33
|
+
ApiError,
|
|
34
|
+
Request,
|
|
35
|
+
Response,
|
|
36
|
+
bad_request,
|
|
37
|
+
clear_cookie,
|
|
38
|
+
error_response,
|
|
39
|
+
ok,
|
|
40
|
+
parse_cookies,
|
|
41
|
+
parse_query,
|
|
42
|
+
redirect,
|
|
43
|
+
set_cookie,
|
|
44
|
+
)
|
|
45
|
+
from commitguard.api.settings import DashboardSettings, Environment
|
|
46
|
+
from commitguard.audit.models import Actor, AuditEventType
|
|
47
|
+
from commitguard.controlplane.access import Permission, Principal, Role
|
|
48
|
+
from commitguard.controlplane.commands import ControlPlaneCommands
|
|
49
|
+
from commitguard.controlplane.errors import ApprovalRequiredError, ControlPlaneError
|
|
50
|
+
from commitguard.controlplane.identity import (
|
|
51
|
+
SESSION_LIFETIME,
|
|
52
|
+
AuthService,
|
|
53
|
+
SessionExpiredError,
|
|
54
|
+
csrf_token_for,
|
|
55
|
+
)
|
|
56
|
+
from commitguard.controlplane.members import MembershipService
|
|
57
|
+
from commitguard.controlplane.notifications import (
|
|
58
|
+
NotificationCenter,
|
|
59
|
+
parse_category_filter,
|
|
60
|
+
parse_state_filter,
|
|
61
|
+
)
|
|
62
|
+
from commitguard.controlplane.pagination import (
|
|
63
|
+
encode_cursor,
|
|
64
|
+
offset_cursor,
|
|
65
|
+
parse_choice,
|
|
66
|
+
parse_int_id,
|
|
67
|
+
parse_limit,
|
|
68
|
+
parse_search,
|
|
69
|
+
parse_timestamp,
|
|
70
|
+
)
|
|
71
|
+
from commitguard.controlplane.policies import (
|
|
72
|
+
REAUTHENTICATION_WINDOW,
|
|
73
|
+
OrganizationPolicyService,
|
|
74
|
+
policy_changes,
|
|
75
|
+
validate_defaults,
|
|
76
|
+
validate_floors,
|
|
77
|
+
)
|
|
78
|
+
from commitguard.controlplane.queries import (
|
|
79
|
+
AUDIT_SORTS,
|
|
80
|
+
PERIODS,
|
|
81
|
+
REPOSITORY_SORTS,
|
|
82
|
+
SCAN_RESULTS,
|
|
83
|
+
SCAN_SORTS,
|
|
84
|
+
VIOLATION_SORTS,
|
|
85
|
+
AuditFilters,
|
|
86
|
+
DashboardQueries,
|
|
87
|
+
RepositoryFilters,
|
|
88
|
+
ScanFilters,
|
|
89
|
+
ViolationFilters,
|
|
90
|
+
)
|
|
91
|
+
from commitguard.controlplane.rules import list_rules, rule_detail
|
|
92
|
+
from commitguard.controlplane.views import (
|
|
93
|
+
OrganizationRef,
|
|
94
|
+
OrganizationView,
|
|
95
|
+
ProtectionStatus,
|
|
96
|
+
SessionInfo,
|
|
97
|
+
UserView,
|
|
98
|
+
ViolationStatus,
|
|
99
|
+
)
|
|
100
|
+
from commitguard.core.decision import Action
|
|
101
|
+
from commitguard.core.result import Severity
|
|
102
|
+
from commitguard.governance.service import GovernanceServices
|
|
103
|
+
from commitguard.notifications.models import NotificationState
|
|
104
|
+
from commitguard.observability.logging import correlation, get_logger
|
|
105
|
+
from commitguard.policies.defaults import KNOWN_POLICY_IDS
|
|
106
|
+
from commitguard.security.rate_limit import RequestRateLimiter
|
|
107
|
+
|
|
108
|
+
log = get_logger(__name__)
|
|
109
|
+
|
|
110
|
+
API_PREFIX = "/api/v1"
|
|
111
|
+
SESSION_COOKIE = "__Host-commitguard_session"
|
|
112
|
+
STATE_COOKIE = "__Host-commitguard_oauth_state"
|
|
113
|
+
UNSAFE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
|
|
114
|
+
|
|
115
|
+
_CONVERTERS = {
|
|
116
|
+
"int": (r"[1-9][0-9]{0,15}", int),
|
|
117
|
+
"hex": (r"[0-9a-f]{32}", str),
|
|
118
|
+
"ident": (r"[a-z][a-z0-9_]{0,63}", str),
|
|
119
|
+
"session": (r"[0-9a-f]{16}", str),
|
|
120
|
+
"version": (r"[1-9][0-9]{0,8}", int),
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
#: requests per minute, per user (or client address before sign-in)
|
|
124
|
+
RATE_LIMITS = {
|
|
125
|
+
"auth": 20,
|
|
126
|
+
"read": 600,
|
|
127
|
+
"search": 120,
|
|
128
|
+
"write": 30,
|
|
129
|
+
"github": 10,
|
|
130
|
+
# policy rollback, organization notification settings, webhook endpoints
|
|
131
|
+
"sensitive": 10,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True, slots=True)
|
|
136
|
+
class Route:
|
|
137
|
+
method: str
|
|
138
|
+
template: str
|
|
139
|
+
handler: Callable[[Request], Response]
|
|
140
|
+
permission: Permission | None
|
|
141
|
+
public: bool
|
|
142
|
+
rate: str
|
|
143
|
+
pattern: re.Pattern[str]
|
|
144
|
+
converters: Mapping[str, Callable[[str], Any]]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _compile(template: str) -> tuple[re.Pattern[str], dict[str, Callable[[str], Any]]]:
|
|
148
|
+
converters: dict[str, Callable[[str], Any]] = {}
|
|
149
|
+
|
|
150
|
+
def replace(match: re.Match[str]) -> str:
|
|
151
|
+
name, kind = match.group(1), match.group(2)
|
|
152
|
+
regex, convert = _CONVERTERS[kind]
|
|
153
|
+
converters[name] = convert
|
|
154
|
+
return f"(?P<{name}>{regex})"
|
|
155
|
+
|
|
156
|
+
pattern = re.sub(
|
|
157
|
+
r"\{([a-z_]+):([a-z]+)\}",
|
|
158
|
+
replace,
|
|
159
|
+
re.escape(template).replace(r"\{", "{").replace(r"\}", "}"),
|
|
160
|
+
)
|
|
161
|
+
return re.compile(rf"\A{pattern}\Z"), converters
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _page_meta(next_cursor: str | None, limit: int, **extra: Any) -> dict[str, Any]:
|
|
165
|
+
return {"next_cursor": next_cursor, "limit": limit, **extra}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class DashboardApi:
|
|
169
|
+
def __init__(
|
|
170
|
+
self,
|
|
171
|
+
*,
|
|
172
|
+
settings: DashboardSettings,
|
|
173
|
+
auth: AuthService,
|
|
174
|
+
queries: DashboardQueries,
|
|
175
|
+
commands: ControlPlaneCommands,
|
|
176
|
+
policies: OrganizationPolicyService,
|
|
177
|
+
members: MembershipService,
|
|
178
|
+
notifications: NotificationCenter,
|
|
179
|
+
governance: GovernanceServices | None = None,
|
|
180
|
+
clock: Callable[[], float] = time.monotonic,
|
|
181
|
+
now: Callable[[], datetime] = lambda: datetime.now(UTC),
|
|
182
|
+
) -> None:
|
|
183
|
+
self.settings = settings
|
|
184
|
+
self._auth = auth
|
|
185
|
+
self._queries = queries
|
|
186
|
+
self._commands = commands
|
|
187
|
+
self._policies = policies
|
|
188
|
+
self._members = members
|
|
189
|
+
self._notifications = notifications
|
|
190
|
+
self._governance = governance
|
|
191
|
+
self._now = now
|
|
192
|
+
self._limiters = {k: RequestRateLimiter(v, clock) for k, v in RATE_LIMITS.items()}
|
|
193
|
+
self._routes = self._build_routes()
|
|
194
|
+
|
|
195
|
+
# ------------------------------------------------------------------ #
|
|
196
|
+
# WSGI
|
|
197
|
+
# ------------------------------------------------------------------ #
|
|
198
|
+
def __call__(self, environ: WSGIEnvironment, start_response: StartResponse) -> Iterable[bytes]:
|
|
199
|
+
started = time.monotonic()
|
|
200
|
+
request_id = uuid.uuid4().hex
|
|
201
|
+
method = str(environ.get("REQUEST_METHOD", "GET")).upper()
|
|
202
|
+
path = str(environ.get("PATH_INFO", ""))
|
|
203
|
+
origin = environ.get("HTTP_ORIGIN")
|
|
204
|
+
request: Request | None = None
|
|
205
|
+
try:
|
|
206
|
+
if method == "OPTIONS":
|
|
207
|
+
response = self._preflight(origin, environ)
|
|
208
|
+
else:
|
|
209
|
+
request = self._parse(environ, method, path, request_id)
|
|
210
|
+
with correlation(request_id=request_id):
|
|
211
|
+
response = self._dispatch(request)
|
|
212
|
+
except ApiError as exc:
|
|
213
|
+
response = error_response(exc, request_id)
|
|
214
|
+
except ControlPlaneError as exc:
|
|
215
|
+
response = error_response(ApiError.from_control_plane(exc), request_id)
|
|
216
|
+
except Exception as exc: # noqa: BLE001 - never leak internals to the client
|
|
217
|
+
log.error("api_internal_error", request_id=request_id, error_type=type(exc).__name__)
|
|
218
|
+
response = error_response(
|
|
219
|
+
ApiError(500, "INTERNAL_ERROR", "Something went wrong. Try again."), request_id
|
|
220
|
+
)
|
|
221
|
+
headers = list(response.headers)
|
|
222
|
+
headers.extend(API_SECURITY_HEADERS)
|
|
223
|
+
headers.append(("X-Request-ID", request_id))
|
|
224
|
+
if self.settings.environment is Environment.PRODUCTION and self.settings.https:
|
|
225
|
+
headers.append(("Strict-Transport-Security", "max-age=63072000; includeSubDomains"))
|
|
226
|
+
headers.extend(self._cors_headers(origin))
|
|
227
|
+
headers.append(("Content-Length", str(len(response.body))))
|
|
228
|
+
start_response(f"{response.status} {REASONS.get(response.status, 'Error')}", headers)
|
|
229
|
+
log.info(
|
|
230
|
+
"api_request",
|
|
231
|
+
request_id=request_id,
|
|
232
|
+
method=method,
|
|
233
|
+
route=request.route if request else "preflight",
|
|
234
|
+
status=response.status,
|
|
235
|
+
duration_ms=round((time.monotonic() - started) * 1000, 1),
|
|
236
|
+
user_id=request.principal.user_id if request and request.principal else None,
|
|
237
|
+
)
|
|
238
|
+
return [response.body] if method != "HEAD" else [b""]
|
|
239
|
+
|
|
240
|
+
def _parse(self, environ: WSGIEnvironment, method: str, path: str, request_id: str) -> Request:
|
|
241
|
+
headers = {
|
|
242
|
+
key[5:].replace("_", "-").lower(): value
|
|
243
|
+
for key, value in environ.items()
|
|
244
|
+
if key.startswith("HTTP_") and isinstance(value, str)
|
|
245
|
+
}
|
|
246
|
+
if environ.get("CONTENT_TYPE"):
|
|
247
|
+
headers["content-type"] = str(environ["CONTENT_TYPE"])
|
|
248
|
+
body = b""
|
|
249
|
+
if method in UNSAFE_METHODS:
|
|
250
|
+
raw_length = str(environ.get("CONTENT_LENGTH", "") or "0")
|
|
251
|
+
if not raw_length.isascii() or not raw_length.isdigit():
|
|
252
|
+
raise ApiError(411, "LENGTH_REQUIRED", "A valid Content-Length is required.")
|
|
253
|
+
length = int(raw_length)
|
|
254
|
+
if length > MAX_BODY_BYTES:
|
|
255
|
+
raise ApiError(413, "PAYLOAD_TOO_LARGE", "The request body is too large.")
|
|
256
|
+
body = environ["wsgi.input"].read(length) if length else b""
|
|
257
|
+
if len(body) != length:
|
|
258
|
+
raise bad_request("The request body is incomplete.")
|
|
259
|
+
return Request(
|
|
260
|
+
method=method,
|
|
261
|
+
path=path,
|
|
262
|
+
query=parse_query(environ),
|
|
263
|
+
headers=headers,
|
|
264
|
+
cookies=parse_cookies(headers.get("cookie")),
|
|
265
|
+
body=body,
|
|
266
|
+
remote_addr=str(environ.get("REMOTE_ADDR", "unknown")),
|
|
267
|
+
request_id=request_id,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def _match(self, request: Request) -> Route:
|
|
271
|
+
allowed: list[str] = []
|
|
272
|
+
for route in self._routes:
|
|
273
|
+
match = route.pattern.match(request.path)
|
|
274
|
+
if match is None:
|
|
275
|
+
continue
|
|
276
|
+
if route.method != request.method and not (
|
|
277
|
+
request.method == "HEAD" and route.method == "GET"
|
|
278
|
+
):
|
|
279
|
+
allowed.append(route.method)
|
|
280
|
+
continue
|
|
281
|
+
request.params = {k: route.converters[k](v) for k, v in match.groupdict().items()}
|
|
282
|
+
request.route = f"{route.method} {route.template}"
|
|
283
|
+
return route
|
|
284
|
+
if allowed:
|
|
285
|
+
raise ApiError(
|
|
286
|
+
405,
|
|
287
|
+
"METHOD_NOT_ALLOWED",
|
|
288
|
+
"This method is not allowed for this resource.",
|
|
289
|
+
headers=[("Allow", ", ".join(sorted(set(allowed))))],
|
|
290
|
+
)
|
|
291
|
+
raise ApiError(404, "NOT_FOUND", NOT_FOUND)
|
|
292
|
+
|
|
293
|
+
def _dispatch(self, request: Request) -> Response:
|
|
294
|
+
route = self._match(request)
|
|
295
|
+
if not route.public:
|
|
296
|
+
self._authenticate(request)
|
|
297
|
+
principal = request.principal
|
|
298
|
+
category = "search" if route.rate == "read" and request.arg("q") else route.rate
|
|
299
|
+
key = f"user:{principal.user_id}" if principal else f"addr:{request.remote_addr}"
|
|
300
|
+
if not self._limiters[category].allow(key):
|
|
301
|
+
raise ApiError(
|
|
302
|
+
429,
|
|
303
|
+
"RATE_LIMITED",
|
|
304
|
+
"Too many requests. Wait a minute and try again.",
|
|
305
|
+
headers=[("Retry-After", "60")],
|
|
306
|
+
)
|
|
307
|
+
if request.method in UNSAFE_METHODS:
|
|
308
|
+
self._check_csrf(request)
|
|
309
|
+
if route.permission is not None and principal is not None:
|
|
310
|
+
if not principal.accounts_with(route.permission):
|
|
311
|
+
raise ApiError(
|
|
312
|
+
403, "FORBIDDEN", "You do not have permission to access this resource."
|
|
313
|
+
)
|
|
314
|
+
organization = parse_int_id(request.arg("organization"), "organization")
|
|
315
|
+
if organization is not None and organization not in principal.accounts_with(
|
|
316
|
+
route.permission
|
|
317
|
+
):
|
|
318
|
+
raise ApiError(404, "NOT_FOUND", NOT_FOUND)
|
|
319
|
+
request.scope = principal.scope(route.permission, account_id=organization)
|
|
320
|
+
return route.handler(request)
|
|
321
|
+
|
|
322
|
+
def _authenticate(self, request: Request) -> None:
|
|
323
|
+
token = request.cookies.get(SESSION_COOKIE)
|
|
324
|
+
try:
|
|
325
|
+
principal = self._auth.authenticate(token)
|
|
326
|
+
except SessionExpiredError as exc:
|
|
327
|
+
raise ApiError(
|
|
328
|
+
401, exc.code, str(exc), headers=[clear_cookie(SESSION_COOKIE)]
|
|
329
|
+
) from None
|
|
330
|
+
if principal is None:
|
|
331
|
+
raise ApiError(401, "UNAUTHENTICATED", "Sign in to continue.")
|
|
332
|
+
request.principal = principal
|
|
333
|
+
request.session_token = token
|
|
334
|
+
|
|
335
|
+
def _check_csrf(self, request: Request) -> None:
|
|
336
|
+
origin = request.header("origin")
|
|
337
|
+
if origin is None or origin not in self.settings.trusted_origins:
|
|
338
|
+
raise ApiError(403, "CSRF_FAILED", "The request origin is not allowed.")
|
|
339
|
+
if not self._auth.verify_csrf(request.session_token, request.header("x-csrf-token")):
|
|
340
|
+
raise ApiError(403, "CSRF_FAILED", "The request is missing a valid CSRF token.")
|
|
341
|
+
|
|
342
|
+
def _cors_headers(self, origin: str | None) -> list[tuple[str, str]]:
|
|
343
|
+
if origin is None or origin not in self.settings.cors_origins:
|
|
344
|
+
return [("Vary", "Origin")]
|
|
345
|
+
return [
|
|
346
|
+
("Access-Control-Allow-Origin", origin),
|
|
347
|
+
("Access-Control-Allow-Credentials", "true"),
|
|
348
|
+
("Access-Control-Expose-Headers", "X-Request-ID"),
|
|
349
|
+
("Vary", "Origin"),
|
|
350
|
+
]
|
|
351
|
+
|
|
352
|
+
def _preflight(self, origin: str | None, environ: WSGIEnvironment) -> Response:
|
|
353
|
+
if origin is None or origin not in self.settings.cors_origins:
|
|
354
|
+
raise ApiError(
|
|
355
|
+
403, "CORS_REJECTED", "Cross-origin requests from this origin are not allowed."
|
|
356
|
+
)
|
|
357
|
+
return Response(
|
|
358
|
+
204,
|
|
359
|
+
b"",
|
|
360
|
+
[
|
|
361
|
+
("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE"),
|
|
362
|
+
("Access-Control-Allow-Headers", "Content-Type, X-CSRF-Token"),
|
|
363
|
+
("Access-Control-Max-Age", "600"),
|
|
364
|
+
],
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
# ------------------------------------------------------------------ #
|
|
368
|
+
# Helpers
|
|
369
|
+
# ------------------------------------------------------------------ #
|
|
370
|
+
@staticmethod
|
|
371
|
+
def _principal(request: Request) -> Principal:
|
|
372
|
+
if request.principal is None: # pragma: no cover - guaranteed by _dispatch
|
|
373
|
+
raise ApiError(401, "UNAUTHENTICATED", "Sign in to continue.")
|
|
374
|
+
return request.principal
|
|
375
|
+
|
|
376
|
+
@staticmethod
|
|
377
|
+
def _scope(request: Request): # type: ignore[no-untyped-def]
|
|
378
|
+
if request.scope is None: # pragma: no cover - guaranteed by _dispatch
|
|
379
|
+
raise ApiError(403, "FORBIDDEN", "You do not have permission to access this resource.")
|
|
380
|
+
return request.scope
|
|
381
|
+
|
|
382
|
+
@staticmethod
|
|
383
|
+
def _organization(
|
|
384
|
+
principal: Principal, organization_id: int, permission: Permission
|
|
385
|
+
) -> OrganizationRef:
|
|
386
|
+
"""An organization the caller belongs to (else 404), after checking ``permission``."""
|
|
387
|
+
membership = principal.memberships.get(organization_id)
|
|
388
|
+
if membership is None:
|
|
389
|
+
raise ApiError(404, "NOT_FOUND", NOT_FOUND)
|
|
390
|
+
if permission not in membership.role.permissions:
|
|
391
|
+
raise ApiError(403, "FORBIDDEN", "You do not have permission to perform this action.")
|
|
392
|
+
return OrganizationRef(
|
|
393
|
+
id=organization_id, login=membership.account_login, type=membership.account_type
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
@staticmethod
|
|
397
|
+
def _hex_arg(request: Request, name: str) -> str | None:
|
|
398
|
+
raw = request.arg(name)
|
|
399
|
+
if raw is None or raw == "":
|
|
400
|
+
return None
|
|
401
|
+
if len(raw) != 32 or any(ch not in "0123456789abcdef" for ch in raw):
|
|
402
|
+
raise bad_request(f"{name} must be a resource ID", name)
|
|
403
|
+
return raw
|
|
404
|
+
|
|
405
|
+
@staticmethod
|
|
406
|
+
def _bool(value: object, field: str) -> bool:
|
|
407
|
+
if not isinstance(value, bool):
|
|
408
|
+
raise bad_request(f"{field} must be true or false", field=field)
|
|
409
|
+
return value
|
|
410
|
+
|
|
411
|
+
# ------------------------------------------------------------------ #
|
|
412
|
+
# Authentication
|
|
413
|
+
# ------------------------------------------------------------------ #
|
|
414
|
+
def login(self, request: Request) -> Response:
|
|
415
|
+
start = self._auth.begin_sign_in(request.arg("return_to"))
|
|
416
|
+
return redirect(start.authorize_url, [set_cookie(STATE_COOKIE, start.state, max_age=600)])
|
|
417
|
+
|
|
418
|
+
def callback(self, request: Request) -> Response:
|
|
419
|
+
if request.arg("error"):
|
|
420
|
+
return redirect("/login?error=access_denied", [clear_cookie(STATE_COOKIE)])
|
|
421
|
+
try:
|
|
422
|
+
completed = self._auth.complete_sign_in(
|
|
423
|
+
code=request.arg("code"),
|
|
424
|
+
state=request.arg("state"),
|
|
425
|
+
cookie_state=request.cookies.get(STATE_COOKIE),
|
|
426
|
+
user_agent=request.header("user-agent") or "",
|
|
427
|
+
)
|
|
428
|
+
except ControlPlaneError as exc:
|
|
429
|
+
code = "github_unavailable" if exc.code == "GITHUB_UNAVAILABLE" else "sign_in_failed"
|
|
430
|
+
log.warning("sign_in_failed", request_id=request.request_id, code=exc.code)
|
|
431
|
+
return redirect(f"/login?error={code}", [clear_cookie(STATE_COOKIE)])
|
|
432
|
+
return redirect(
|
|
433
|
+
quote(completed.return_to, safe="/?=&%-_.~"),
|
|
434
|
+
[
|
|
435
|
+
clear_cookie(STATE_COOKIE),
|
|
436
|
+
set_cookie(
|
|
437
|
+
SESSION_COOKIE,
|
|
438
|
+
completed.session_token.reveal(),
|
|
439
|
+
max_age=int(SESSION_LIFETIME.total_seconds()),
|
|
440
|
+
),
|
|
441
|
+
],
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
def session(self, request: Request) -> Response:
|
|
445
|
+
principal = self._principal(request)
|
|
446
|
+
sessions = self._auth.list_sessions(principal)
|
|
447
|
+
current = next(s for s in sessions if s.current)
|
|
448
|
+
organizations = self._organization_views(principal)
|
|
449
|
+
info = SessionInfo(
|
|
450
|
+
user=UserView(id=principal.user_id, login=principal.login),
|
|
451
|
+
session=current,
|
|
452
|
+
csrf_token=csrf_token_for(request.session_token or ""),
|
|
453
|
+
organizations=organizations,
|
|
454
|
+
reauthentication_required_after=principal.authenticated_at + REAUTHENTICATION_WINDOW,
|
|
455
|
+
)
|
|
456
|
+
return ok(info)
|
|
457
|
+
|
|
458
|
+
def logout(self, request: Request) -> Response:
|
|
459
|
+
self._auth.sign_out(self._principal(request))
|
|
460
|
+
response = ok({"signed_out": True})
|
|
461
|
+
response.headers.append(clear_cookie(SESSION_COOKIE))
|
|
462
|
+
return response
|
|
463
|
+
|
|
464
|
+
def sessions(self, request: Request) -> Response:
|
|
465
|
+
return ok(self._auth.list_sessions(self._principal(request)))
|
|
466
|
+
|
|
467
|
+
def revoke_session(self, request: Request) -> Response:
|
|
468
|
+
principal = self._principal(request)
|
|
469
|
+
self._auth.revoke_session(principal, request.params["session_id"])
|
|
470
|
+
response = ok({"revoked": True})
|
|
471
|
+
if request.params["session_id"] == principal.session_public_id:
|
|
472
|
+
response.headers.append(clear_cookie(SESSION_COOKIE))
|
|
473
|
+
return response
|
|
474
|
+
|
|
475
|
+
# ------------------------------------------------------------------ #
|
|
476
|
+
# Overview, organizations and members
|
|
477
|
+
# ------------------------------------------------------------------ #
|
|
478
|
+
def overview(self, request: Request) -> Response:
|
|
479
|
+
period = parse_choice(request.arg("period"), {k: k for k in PERIODS}, "period") or "7d"
|
|
480
|
+
organization = parse_int_id(request.arg("organization"), "organization")
|
|
481
|
+
view = self._queries.overview(
|
|
482
|
+
self._principal(request), period=period, organization_id=organization
|
|
483
|
+
)
|
|
484
|
+
return ok(view)
|
|
485
|
+
|
|
486
|
+
@staticmethod
|
|
487
|
+
def _organization_views(principal: Principal) -> tuple[OrganizationView, ...]:
|
|
488
|
+
return tuple(
|
|
489
|
+
OrganizationView(
|
|
490
|
+
organization=OrganizationRef(
|
|
491
|
+
id=m.account_id, login=m.account_login, type=m.account_type
|
|
492
|
+
),
|
|
493
|
+
role=m.role.value,
|
|
494
|
+
implicit_role=m.implicit,
|
|
495
|
+
permissions=tuple(sorted(p.value for p in m.role.permissions)),
|
|
496
|
+
installation_ids=tuple(
|
|
497
|
+
sorted(i for i, a in principal.installations.items() if a == m.account_id)
|
|
498
|
+
),
|
|
499
|
+
)
|
|
500
|
+
for m in sorted(principal.memberships.values(), key=lambda m: m.account_login.lower())
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
def organizations(self, request: Request) -> Response:
|
|
504
|
+
return ok(self._organization_views(self._principal(request)))
|
|
505
|
+
|
|
506
|
+
def list_members(self, request: Request) -> Response:
|
|
507
|
+
principal = self._principal(request)
|
|
508
|
+
organization = self._organization(
|
|
509
|
+
principal, request.params["organization_id"], Permission.MEMBERS_READ
|
|
510
|
+
)
|
|
511
|
+
offset = offset_cursor(request.arg("cursor"))
|
|
512
|
+
limit = parse_limit(request.arg("limit"))
|
|
513
|
+
members = self._members.list_members(organization.id, offset=offset, limit=limit + 1)
|
|
514
|
+
next_cursor = None
|
|
515
|
+
if len(members) > limit:
|
|
516
|
+
members = members[:limit]
|
|
517
|
+
next_cursor = encode_cursor([offset + limit])
|
|
518
|
+
return ok(members, _page_meta(next_cursor, limit))
|
|
519
|
+
|
|
520
|
+
def put_member(self, request: Request) -> Response:
|
|
521
|
+
principal = self._principal(request)
|
|
522
|
+
organization = self._organization(
|
|
523
|
+
principal, request.params["organization_id"], Permission.MEMBERS_MANAGE
|
|
524
|
+
)
|
|
525
|
+
body = request.json()
|
|
526
|
+
role = parse_choice(
|
|
527
|
+
body.get("role") if isinstance(body.get("role"), str) else "",
|
|
528
|
+
{r.value: r for r in Role},
|
|
529
|
+
"role",
|
|
530
|
+
)
|
|
531
|
+
if role is None:
|
|
532
|
+
raise bad_request("role is required", field="role")
|
|
533
|
+
login = body.get("login")
|
|
534
|
+
if login is not None and not isinstance(login, str):
|
|
535
|
+
raise bad_request("login must be text", field="login")
|
|
536
|
+
member = self._members.grant(
|
|
537
|
+
account_id=organization.id,
|
|
538
|
+
user_id=request.params["user_id"],
|
|
539
|
+
role=role,
|
|
540
|
+
actor=Actor.user(principal.user_id, principal.login),
|
|
541
|
+
login=login,
|
|
542
|
+
)
|
|
543
|
+
return ok(member)
|
|
544
|
+
|
|
545
|
+
def delete_member(self, request: Request) -> Response:
|
|
546
|
+
principal = self._principal(request)
|
|
547
|
+
organization = self._organization(
|
|
548
|
+
principal, request.params["organization_id"], Permission.MEMBERS_MANAGE
|
|
549
|
+
)
|
|
550
|
+
self._members.remove(
|
|
551
|
+
account_id=organization.id,
|
|
552
|
+
user_id=request.params["user_id"],
|
|
553
|
+
actor=Actor.user(principal.user_id, principal.login),
|
|
554
|
+
)
|
|
555
|
+
return ok({"removed": True})
|
|
556
|
+
|
|
557
|
+
# ------------------------------------------------------------------ #
|
|
558
|
+
# Repositories
|
|
559
|
+
# ------------------------------------------------------------------ #
|
|
560
|
+
def list_repositories(self, request: Request) -> Response:
|
|
561
|
+
filters = RepositoryFilters(
|
|
562
|
+
organization_id=parse_int_id(request.arg("organization"), "organization"),
|
|
563
|
+
protection=parse_choice(
|
|
564
|
+
request.arg("protection"), {p.value: p for p in ProtectionStatus}, "protection"
|
|
565
|
+
),
|
|
566
|
+
q=parse_search(request.arg("q")),
|
|
567
|
+
sort=parse_choice(request.arg("sort"), {s: s for s in REPOSITORY_SORTS}, "sort")
|
|
568
|
+
or "name",
|
|
569
|
+
)
|
|
570
|
+
limit = parse_limit(request.arg("limit"))
|
|
571
|
+
page = self._queries.list_repositories(
|
|
572
|
+
self._scope(request), filters, offset=offset_cursor(request.arg("cursor")), limit=limit
|
|
573
|
+
)
|
|
574
|
+
return ok(page.items, _page_meta(page.next_cursor, page.limit))
|
|
575
|
+
|
|
576
|
+
def get_repository(self, request: Request) -> Response:
|
|
577
|
+
detail = self._queries.get_repository(
|
|
578
|
+
self._scope(request),
|
|
579
|
+
request.params["repository_id"],
|
|
580
|
+
principal=self._principal(request),
|
|
581
|
+
)
|
|
582
|
+
if detail is None:
|
|
583
|
+
raise ApiError(404, "NOT_FOUND", NOT_FOUND)
|
|
584
|
+
return ok(detail)
|
|
585
|
+
|
|
586
|
+
def repository_merge_queue(self, request: Request) -> Response:
|
|
587
|
+
view = self._queries.merge_queue(self._scope(request), request.params["repository_id"])
|
|
588
|
+
if view is None:
|
|
589
|
+
raise ApiError(404, "NOT_FOUND", NOT_FOUND)
|
|
590
|
+
return ok(view)
|
|
591
|
+
|
|
592
|
+
def put_monitoring(self, request: Request) -> Response:
|
|
593
|
+
body = request.json()
|
|
594
|
+
self._commands.set_monitoring(
|
|
595
|
+
self._principal(request),
|
|
596
|
+
request.params["repository_id"],
|
|
597
|
+
enabled=body.get("enabled"),
|
|
598
|
+
confirm=body.get("confirm"),
|
|
599
|
+
reason=body.get("reason"),
|
|
600
|
+
)
|
|
601
|
+
return self.get_repository(request)
|
|
602
|
+
|
|
603
|
+
def refresh_enforcement(self, request: Request) -> Response:
|
|
604
|
+
self._commands.refresh_enforcement(
|
|
605
|
+
self._principal(request), request.params["repository_id"]
|
|
606
|
+
)
|
|
607
|
+
return self.get_repository(request)
|
|
608
|
+
|
|
609
|
+
# ------------------------------------------------------------------ #
|
|
610
|
+
# Scans
|
|
611
|
+
# ------------------------------------------------------------------ #
|
|
612
|
+
def list_scans(self, request: Request) -> Response:
|
|
613
|
+
rule = request.arg("rule")
|
|
614
|
+
if rule and rule not in KNOWN_POLICY_IDS:
|
|
615
|
+
raise bad_request("unknown rule", field="rule")
|
|
616
|
+
filters = ScanFilters(
|
|
617
|
+
organization_id=parse_int_id(request.arg("organization"), "organization"),
|
|
618
|
+
repository_id=parse_int_id(request.arg("repository"), "repository"),
|
|
619
|
+
result=parse_choice(request.arg("result"), dict(SCAN_RESULTS), "result"),
|
|
620
|
+
event=parse_choice(
|
|
621
|
+
request.arg("event"), {"pull_request": "pull_request", "push": "push"}, "event"
|
|
622
|
+
),
|
|
623
|
+
rule_id=rule or None,
|
|
624
|
+
severity=parse_choice(
|
|
625
|
+
request.arg("severity"), {s.value: s for s in Severity}, "severity"
|
|
626
|
+
),
|
|
627
|
+
start=parse_timestamp(request.arg("from"), "from"),
|
|
628
|
+
end=parse_timestamp(request.arg("to"), "to"),
|
|
629
|
+
q=parse_search(request.arg("q")),
|
|
630
|
+
sort=parse_choice(request.arg("sort"), {s: s for s in SCAN_SORTS}, "sort") or "newest",
|
|
631
|
+
)
|
|
632
|
+
limit = parse_limit(request.arg("limit"))
|
|
633
|
+
page = self._queries.list_scans(
|
|
634
|
+
self._scope(request), filters, cursor=request.arg("cursor"), limit=limit
|
|
635
|
+
)
|
|
636
|
+
return ok(page.items, _page_meta(page.next_cursor, page.limit))
|
|
637
|
+
|
|
638
|
+
def get_scan(self, request: Request) -> Response:
|
|
639
|
+
detail = self._queries.get_scan(
|
|
640
|
+
self._scope(request), request.params["scan_id"], principal=self._principal(request)
|
|
641
|
+
)
|
|
642
|
+
if detail is None:
|
|
643
|
+
raise ApiError(404, "NOT_FOUND", NOT_FOUND)
|
|
644
|
+
return ok(detail)
|
|
645
|
+
|
|
646
|
+
def compare_scan(self, request: Request) -> Response:
|
|
647
|
+
comparison = self._queries.compare_scan(self._scope(request), request.params["scan_id"])
|
|
648
|
+
if comparison is None:
|
|
649
|
+
raise ApiError(404, "NOT_FOUND", NOT_FOUND)
|
|
650
|
+
return ok(comparison)
|
|
651
|
+
|
|
652
|
+
def scan_executions(self, request: Request) -> Response:
|
|
653
|
+
history = self._queries.scan_executions(self._scope(request), request.params["scan_id"])
|
|
654
|
+
if history is None:
|
|
655
|
+
raise ApiError(404, "NOT_FOUND", NOT_FOUND)
|
|
656
|
+
return ok(history)
|
|
657
|
+
|
|
658
|
+
def rescan(self, request: Request) -> Response:
|
|
659
|
+
job_id = self._commands.request_rescan(self._principal(request), request.params["scan_id"])
|
|
660
|
+
return ok({"scan": job_id, "result": "queued"}, status=202)
|
|
661
|
+
|
|
662
|
+
# ------------------------------------------------------------------ #
|
|
663
|
+
# Violations
|
|
664
|
+
# ------------------------------------------------------------------ #
|
|
665
|
+
def list_violations(self, request: Request) -> Response:
|
|
666
|
+
rule = request.arg("rule")
|
|
667
|
+
if rule and rule not in KNOWN_POLICY_IDS:
|
|
668
|
+
raise bad_request("unknown rule", field="rule")
|
|
669
|
+
filters = ViolationFilters(
|
|
670
|
+
organization_id=parse_int_id(request.arg("organization"), "organization"),
|
|
671
|
+
repository_id=parse_int_id(request.arg("repository"), "repository"),
|
|
672
|
+
status=parse_choice(
|
|
673
|
+
request.arg("status"), {s.value: s for s in ViolationStatus}, "status"
|
|
674
|
+
),
|
|
675
|
+
severity=parse_choice(
|
|
676
|
+
request.arg("severity"), {s.value: s for s in Severity}, "severity"
|
|
677
|
+
),
|
|
678
|
+
rule_id=rule or None,
|
|
679
|
+
action=parse_choice(
|
|
680
|
+
request.arg("action"), {a.value: a for a in (Action.BLOCK, Action.WARN)}, "action"
|
|
681
|
+
),
|
|
682
|
+
start=parse_timestamp(request.arg("from"), "from"),
|
|
683
|
+
end=parse_timestamp(request.arg("to"), "to"),
|
|
684
|
+
q=parse_search(request.arg("q")),
|
|
685
|
+
sort=parse_choice(request.arg("sort"), {s: s for s in VIOLATION_SORTS}, "sort")
|
|
686
|
+
or "newest",
|
|
687
|
+
)
|
|
688
|
+
limit = parse_limit(request.arg("limit"))
|
|
689
|
+
page = self._queries.list_violations(
|
|
690
|
+
self._scope(request), filters, cursor=request.arg("cursor"), limit=limit
|
|
691
|
+
)
|
|
692
|
+
return ok(page.items, _page_meta(page.next_cursor, page.limit))
|
|
693
|
+
|
|
694
|
+
def get_violation(self, request: Request) -> Response:
|
|
695
|
+
detail = self._queries.get_violation(
|
|
696
|
+
self._scope(request), request.params["violation_id"], principal=self._principal(request)
|
|
697
|
+
)
|
|
698
|
+
if detail is None:
|
|
699
|
+
raise ApiError(404, "NOT_FOUND", NOT_FOUND)
|
|
700
|
+
return ok(detail)
|
|
701
|
+
|
|
702
|
+
def acknowledge(self, request: Request) -> Response:
|
|
703
|
+
body = request.json()
|
|
704
|
+
self._commands.acknowledge_violation(
|
|
705
|
+
self._principal(request), request.params["violation_id"], body.get("note")
|
|
706
|
+
)
|
|
707
|
+
return self.get_violation(request)
|
|
708
|
+
|
|
709
|
+
def unacknowledge(self, request: Request) -> Response:
|
|
710
|
+
self._commands.remove_acknowledgement(
|
|
711
|
+
self._principal(request), request.params["violation_id"]
|
|
712
|
+
)
|
|
713
|
+
return self.get_violation(request)
|
|
714
|
+
|
|
715
|
+
# ------------------------------------------------------------------ #
|
|
716
|
+
# Policies and rules
|
|
717
|
+
# ------------------------------------------------------------------ #
|
|
718
|
+
def list_policies(self, request: Request) -> Response:
|
|
719
|
+
principal = self._principal(request)
|
|
720
|
+
organization = parse_int_id(request.arg("organization"), "organization")
|
|
721
|
+
views = []
|
|
722
|
+
for account_id in principal.accounts_with(Permission.POLICIES_READ):
|
|
723
|
+
if organization is not None and account_id != organization:
|
|
724
|
+
continue
|
|
725
|
+
membership = principal.memberships[account_id]
|
|
726
|
+
views.append(
|
|
727
|
+
self._policies.view(
|
|
728
|
+
OrganizationRef(
|
|
729
|
+
id=account_id, login=membership.account_login, type=membership.account_type
|
|
730
|
+
),
|
|
731
|
+
can_write=principal.can(Permission.POLICIES_WRITE, account_id),
|
|
732
|
+
)
|
|
733
|
+
)
|
|
734
|
+
return ok(views)
|
|
735
|
+
|
|
736
|
+
def get_policy(self, request: Request) -> Response:
|
|
737
|
+
principal = self._principal(request)
|
|
738
|
+
organization = self._organization(
|
|
739
|
+
principal, request.params["organization_id"], Permission.POLICIES_READ
|
|
740
|
+
)
|
|
741
|
+
return ok(
|
|
742
|
+
self._policies.view(
|
|
743
|
+
organization, can_write=principal.can(Permission.POLICIES_WRITE, organization.id)
|
|
744
|
+
)
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
def preview_policy(self, request: Request) -> Response:
|
|
748
|
+
principal = self._principal(request)
|
|
749
|
+
organization = self._organization(
|
|
750
|
+
principal, request.params["organization_id"], Permission.POLICIES_READ
|
|
751
|
+
)
|
|
752
|
+
floors = validate_floors(request.json().get("floors"))
|
|
753
|
+
current = self._policies.current(organization.id)
|
|
754
|
+
changes = policy_changes(current.floors, floors)
|
|
755
|
+
return ok(
|
|
756
|
+
{
|
|
757
|
+
"version": current.version,
|
|
758
|
+
"changes": changes,
|
|
759
|
+
"weakening": any(c.weakening for c in changes),
|
|
760
|
+
}
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
def put_policy(self, request: Request) -> Response:
|
|
764
|
+
principal = self._principal(request)
|
|
765
|
+
organization = self._organization(
|
|
766
|
+
principal, request.params["organization_id"], Permission.POLICIES_WRITE
|
|
767
|
+
)
|
|
768
|
+
body = request.json()
|
|
769
|
+
expected = body.get("expected_version")
|
|
770
|
+
if not isinstance(expected, int) or isinstance(expected, bool) or expected < 0:
|
|
771
|
+
raise bad_request("expected_version must be a non-negative integer", "expected_version")
|
|
772
|
+
reason = body.get("reason")
|
|
773
|
+
if reason is not None and (not isinstance(reason, str) or len(reason) > 500):
|
|
774
|
+
raise bad_request("reason must be text of at most 500 characters", "reason")
|
|
775
|
+
confirm = body.get("confirm_weakening", False)
|
|
776
|
+
if self._governance is not None:
|
|
777
|
+
settings = self._governance.settings.get(organization.id).settings
|
|
778
|
+
if settings.require_policy_approval:
|
|
779
|
+
raise ApprovalRequiredError(
|
|
780
|
+
"This organization requires policy changes to be approved. Create a policy "
|
|
781
|
+
"draft, submit it for approval, then publish it."
|
|
782
|
+
)
|
|
783
|
+
defaults = body.get("defaults")
|
|
784
|
+
_, changes = self._policies.update(
|
|
785
|
+
account_id=organization.id,
|
|
786
|
+
actor=Actor.user(principal.user_id, principal.login),
|
|
787
|
+
authenticated_at=principal.authenticated_at,
|
|
788
|
+
expected_version=expected,
|
|
789
|
+
floors=validate_floors(body.get("floors")),
|
|
790
|
+
defaults=validate_defaults(defaults) if defaults is not None else None,
|
|
791
|
+
reason=reason,
|
|
792
|
+
confirm_weakening=self._bool(confirm, "confirm_weakening"),
|
|
793
|
+
)
|
|
794
|
+
view = self._policies.view(organization, can_write=True)
|
|
795
|
+
return ok(view, {"changes": [c.model_dump(mode="json") for c in changes]})
|
|
796
|
+
|
|
797
|
+
def policy_versions(self, request: Request) -> Response:
|
|
798
|
+
principal = self._principal(request)
|
|
799
|
+
organization = self._organization(
|
|
800
|
+
principal, request.params["organization_id"], Permission.POLICIES_READ
|
|
801
|
+
)
|
|
802
|
+
offset = offset_cursor(request.arg("cursor"))
|
|
803
|
+
limit = parse_limit(request.arg("limit"))
|
|
804
|
+
versions = self._policies.versions(organization.id, offset=offset, limit=limit + 1)
|
|
805
|
+
next_cursor = encode_cursor([offset + limit]) if len(versions) > limit else None
|
|
806
|
+
return ok(versions[:limit], _page_meta(next_cursor, limit))
|
|
807
|
+
|
|
808
|
+
def policy_version(self, request: Request) -> Response:
|
|
809
|
+
principal = self._principal(request)
|
|
810
|
+
organization = self._organization(
|
|
811
|
+
principal, request.params["organization_id"], Permission.POLICIES_READ
|
|
812
|
+
)
|
|
813
|
+
version = self._policies.version(organization.id, request.params["version"])
|
|
814
|
+
if version is None:
|
|
815
|
+
raise ApiError(404, "NOT_FOUND", NOT_FOUND)
|
|
816
|
+
return ok(self._policies.version_view(version))
|
|
817
|
+
|
|
818
|
+
def policy_diff(self, request: Request) -> Response:
|
|
819
|
+
principal = self._principal(request)
|
|
820
|
+
organization = self._organization(
|
|
821
|
+
principal, request.params["organization_id"], Permission.POLICIES_READ
|
|
822
|
+
)
|
|
823
|
+
from_version = self._version_arg(request, "from")
|
|
824
|
+
to_version = self._version_arg(request, "to")
|
|
825
|
+
diff = self._policies.diff(organization.id, from_version, to_version)
|
|
826
|
+
if diff is None:
|
|
827
|
+
raise ApiError(404, "NOT_FOUND", NOT_FOUND)
|
|
828
|
+
return ok(diff)
|
|
829
|
+
|
|
830
|
+
@staticmethod
|
|
831
|
+
def _version_arg(request: Request, name: str) -> int:
|
|
832
|
+
raw = request.arg(name)
|
|
833
|
+
if raw is None or not raw.isascii() or not raw.isdigit() or len(raw) > 9:
|
|
834
|
+
raise bad_request(f"{name} must be a policy version number", field=name)
|
|
835
|
+
return int(raw)
|
|
836
|
+
|
|
837
|
+
def rollback_policy(self, request: Request) -> Response:
|
|
838
|
+
principal = self._principal(request)
|
|
839
|
+
organization = self._organization(
|
|
840
|
+
principal, request.params["organization_id"], Permission.POLICIES_ROLLBACK
|
|
841
|
+
)
|
|
842
|
+
body = request.json()
|
|
843
|
+
target = body.get("target_version")
|
|
844
|
+
expected = body.get("expected_current_version")
|
|
845
|
+
for name, value in (("target_version", target), ("expected_current_version", expected)):
|
|
846
|
+
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
|
847
|
+
raise bad_request(f"{name} must be a non-negative integer", field=name)
|
|
848
|
+
reason = body.get("reason")
|
|
849
|
+
if reason is not None and (not isinstance(reason, str) or len(reason) > 500):
|
|
850
|
+
raise bad_request("reason must be text of at most 500 characters", "reason")
|
|
851
|
+
created, diff = self._policies.rollback(
|
|
852
|
+
account_id=organization.id,
|
|
853
|
+
actor=Actor.user(principal.user_id, principal.login),
|
|
854
|
+
authenticated_at=principal.authenticated_at,
|
|
855
|
+
target_version=int(target), # type: ignore[arg-type]
|
|
856
|
+
expected_current_version=int(expected), # type: ignore[arg-type]
|
|
857
|
+
reason=reason,
|
|
858
|
+
confirm=self._bool(body.get("confirm", False), "confirm"),
|
|
859
|
+
)
|
|
860
|
+
view = self._policies.view(
|
|
861
|
+
organization, can_write=principal.can(Permission.POLICIES_WRITE, organization.id)
|
|
862
|
+
)
|
|
863
|
+
return ok(
|
|
864
|
+
view,
|
|
865
|
+
{
|
|
866
|
+
"rollback": {
|
|
867
|
+
"new_version": created.version,
|
|
868
|
+
"restored_version": created.restored_version,
|
|
869
|
+
"rollback_of": created.rollback_of,
|
|
870
|
+
"diff": diff.model_dump(mode="json"),
|
|
871
|
+
}
|
|
872
|
+
},
|
|
873
|
+
)
|
|
874
|
+
|
|
875
|
+
def list_rules(self, request: Request) -> Response:
|
|
876
|
+
return ok(list_rules())
|
|
877
|
+
|
|
878
|
+
def get_rule(self, request: Request) -> Response:
|
|
879
|
+
detail = rule_detail(request.params["rule_id"])
|
|
880
|
+
if detail is None:
|
|
881
|
+
raise ApiError(404, "NOT_FOUND", NOT_FOUND)
|
|
882
|
+
return ok(detail)
|
|
883
|
+
|
|
884
|
+
# ------------------------------------------------------------------ #
|
|
885
|
+
# Audit
|
|
886
|
+
# ------------------------------------------------------------------ #
|
|
887
|
+
def list_audit(self, request: Request) -> Response:
|
|
888
|
+
filters = AuditFilters(
|
|
889
|
+
organization_id=parse_int_id(request.arg("organization"), "organization"),
|
|
890
|
+
repository_id=parse_int_id(request.arg("repository"), "repository"),
|
|
891
|
+
event_type=parse_choice(
|
|
892
|
+
request.arg("type"), {t.value: t for t in AuditEventType}, "type"
|
|
893
|
+
),
|
|
894
|
+
actor=parse_search(request.arg("actor")),
|
|
895
|
+
start=parse_timestamp(request.arg("from"), "from"),
|
|
896
|
+
end=parse_timestamp(request.arg("to"), "to"),
|
|
897
|
+
sort=parse_choice(request.arg("sort"), {s: s for s in AUDIT_SORTS}, "sort") or "newest",
|
|
898
|
+
rule_id=parse_choice(request.arg("rule"), {r: r for r in KNOWN_POLICY_IDS}, "rule"),
|
|
899
|
+
exception_id=self._hex_arg(request, "exception"),
|
|
900
|
+
policy=(
|
|
901
|
+
"organization"
|
|
902
|
+
if request.arg("policy") == "organization"
|
|
903
|
+
else self._hex_arg(request, "policy")
|
|
904
|
+
),
|
|
905
|
+
)
|
|
906
|
+
limit = parse_limit(request.arg("limit"))
|
|
907
|
+
page = self._queries.list_audit(
|
|
908
|
+
self._scope(request), filters, cursor=request.arg("cursor"), limit=limit
|
|
909
|
+
)
|
|
910
|
+
return ok(page.items, _page_meta(page.next_cursor, page.limit))
|
|
911
|
+
|
|
912
|
+
def get_audit_event(self, request: Request) -> Response:
|
|
913
|
+
event = self._queries.get_audit_event(self._scope(request), request.params["event_id"])
|
|
914
|
+
if event is None:
|
|
915
|
+
raise ApiError(404, "NOT_FOUND", NOT_FOUND)
|
|
916
|
+
return ok(event)
|
|
917
|
+
|
|
918
|
+
# ------------------------------------------------------------------ #
|
|
919
|
+
# GitHub
|
|
920
|
+
# ------------------------------------------------------------------ #
|
|
921
|
+
def list_installations(self, request: Request) -> Response:
|
|
922
|
+
return ok(self._queries.list_installations(self._scope(request), self._principal(request)))
|
|
923
|
+
|
|
924
|
+
def get_installation(self, request: Request) -> Response:
|
|
925
|
+
detail = self._queries.get_installation(
|
|
926
|
+
self._scope(request), request.params["installation_id"], self._principal(request)
|
|
927
|
+
)
|
|
928
|
+
if detail is None:
|
|
929
|
+
raise ApiError(404, "NOT_FOUND", NOT_FOUND)
|
|
930
|
+
return ok(detail)
|
|
931
|
+
|
|
932
|
+
def installation_repositories(self, request: Request) -> Response:
|
|
933
|
+
limit = parse_limit(request.arg("limit"))
|
|
934
|
+
page = self._queries.installation_repositories(
|
|
935
|
+
self._scope(request),
|
|
936
|
+
request.params["installation_id"],
|
|
937
|
+
offset=offset_cursor(request.arg("cursor")),
|
|
938
|
+
limit=limit,
|
|
939
|
+
)
|
|
940
|
+
if page is None:
|
|
941
|
+
raise ApiError(404, "NOT_FOUND", NOT_FOUND)
|
|
942
|
+
return ok(page.items, _page_meta(page.next_cursor, page.limit))
|
|
943
|
+
|
|
944
|
+
def sync_installation(self, request: Request) -> Response:
|
|
945
|
+
result = self._commands.sync_installation(
|
|
946
|
+
self._principal(request), request.params["installation_id"]
|
|
947
|
+
)
|
|
948
|
+
return ok(result)
|
|
949
|
+
|
|
950
|
+
# ------------------------------------------------------------------ #
|
|
951
|
+
# Notifications
|
|
952
|
+
# ------------------------------------------------------------------ #
|
|
953
|
+
def list_notifications(self, request: Request) -> Response:
|
|
954
|
+
principal = self._principal(request)
|
|
955
|
+
limit = parse_limit(request.arg("limit"))
|
|
956
|
+
page = self._notifications.list_notifications(
|
|
957
|
+
principal,
|
|
958
|
+
state=parse_state_filter(request.arg("state")),
|
|
959
|
+
category=parse_category_filter(request.arg("category")),
|
|
960
|
+
organization_id=parse_int_id(request.arg("organization"), "organization"),
|
|
961
|
+
cursor=request.arg("cursor"),
|
|
962
|
+
limit=limit,
|
|
963
|
+
)
|
|
964
|
+
counts = self._notifications.counts(principal)
|
|
965
|
+
return ok(page.items, _page_meta(page.next_cursor, page.limit, counts=counts.model_dump()))
|
|
966
|
+
|
|
967
|
+
def notification_counts(self, request: Request) -> Response:
|
|
968
|
+
return ok(self._notifications.counts(self._principal(request)))
|
|
969
|
+
|
|
970
|
+
def get_notification(self, request: Request) -> Response:
|
|
971
|
+
view = self._notifications.get(self._principal(request), request.params["notification_id"])
|
|
972
|
+
if view is None:
|
|
973
|
+
raise ApiError(404, "NOT_FOUND", NOT_FOUND)
|
|
974
|
+
return ok(view)
|
|
975
|
+
|
|
976
|
+
def read_notification(self, request: Request) -> Response:
|
|
977
|
+
return ok(
|
|
978
|
+
self._notifications.set_state(
|
|
979
|
+
self._principal(request), request.params["notification_id"], NotificationState.READ
|
|
980
|
+
)
|
|
981
|
+
)
|
|
982
|
+
|
|
983
|
+
def unread_notification(self, request: Request) -> Response:
|
|
984
|
+
return ok(
|
|
985
|
+
self._notifications.set_state(
|
|
986
|
+
self._principal(request),
|
|
987
|
+
request.params["notification_id"],
|
|
988
|
+
NotificationState.UNREAD,
|
|
989
|
+
)
|
|
990
|
+
)
|
|
991
|
+
|
|
992
|
+
def archive_notification(self, request: Request) -> Response:
|
|
993
|
+
return ok(
|
|
994
|
+
self._notifications.set_state(
|
|
995
|
+
self._principal(request),
|
|
996
|
+
request.params["notification_id"],
|
|
997
|
+
NotificationState.ARCHIVED,
|
|
998
|
+
)
|
|
999
|
+
)
|
|
1000
|
+
|
|
1001
|
+
def read_all_notifications(self, request: Request) -> Response:
|
|
1002
|
+
body = request.json()
|
|
1003
|
+
organization = body.get("organization_id")
|
|
1004
|
+
if organization is not None and (
|
|
1005
|
+
not isinstance(organization, int) or isinstance(organization, bool)
|
|
1006
|
+
):
|
|
1007
|
+
raise bad_request("organization_id must be an integer", field="organization_id")
|
|
1008
|
+
updated = self._notifications.mark_all_read(self._principal(request), organization)
|
|
1009
|
+
return ok({"updated": updated})
|
|
1010
|
+
|
|
1011
|
+
def notification_preferences(self, request: Request) -> Response:
|
|
1012
|
+
return ok(self._notifications.preferences(self._principal(request)))
|
|
1013
|
+
|
|
1014
|
+
def patch_notification_preferences(self, request: Request) -> Response:
|
|
1015
|
+
body = request.json()
|
|
1016
|
+
return ok(
|
|
1017
|
+
self._notifications.update_personal(
|
|
1018
|
+
self._principal(request), body.get("organization_id"), body.get("in_app")
|
|
1019
|
+
)
|
|
1020
|
+
)
|
|
1021
|
+
|
|
1022
|
+
def notification_settings(self, request: Request) -> Response:
|
|
1023
|
+
return ok(
|
|
1024
|
+
self._notifications.organization_settings(
|
|
1025
|
+
self._principal(request), request.params["organization_id"]
|
|
1026
|
+
)
|
|
1027
|
+
)
|
|
1028
|
+
|
|
1029
|
+
def put_notification_settings(self, request: Request) -> Response:
|
|
1030
|
+
return ok(
|
|
1031
|
+
self._notifications.update_organization(
|
|
1032
|
+
self._principal(request), request.params["organization_id"], request.json()
|
|
1033
|
+
)
|
|
1034
|
+
)
|
|
1035
|
+
|
|
1036
|
+
def add_notification_webhook(self, request: Request) -> Response:
|
|
1037
|
+
created = self._notifications.add_webhook(
|
|
1038
|
+
self._principal(request), request.params["organization_id"], request.json()
|
|
1039
|
+
)
|
|
1040
|
+
return ok(created, status=201)
|
|
1041
|
+
|
|
1042
|
+
def delete_notification_webhook(self, request: Request) -> Response:
|
|
1043
|
+
self._notifications.remove_webhook(
|
|
1044
|
+
self._principal(request),
|
|
1045
|
+
request.params["organization_id"],
|
|
1046
|
+
request.params["endpoint_id"],
|
|
1047
|
+
)
|
|
1048
|
+
return ok({"removed": True})
|
|
1049
|
+
|
|
1050
|
+
def notification_deliveries(self, request: Request) -> Response:
|
|
1051
|
+
limit = parse_limit(request.arg("limit"))
|
|
1052
|
+
page = self._notifications.deliveries(
|
|
1053
|
+
self._principal(request),
|
|
1054
|
+
request.params["organization_id"],
|
|
1055
|
+
cursor=request.arg("cursor"),
|
|
1056
|
+
limit=limit,
|
|
1057
|
+
)
|
|
1058
|
+
return ok(page.items, _page_meta(page.next_cursor, page.limit))
|
|
1059
|
+
|
|
1060
|
+
# ------------------------------------------------------------------ #
|
|
1061
|
+
# Route table
|
|
1062
|
+
# ------------------------------------------------------------------ #
|
|
1063
|
+
def _build_routes(self) -> tuple[Route, ...]:
|
|
1064
|
+
p = Permission
|
|
1065
|
+
table: list[
|
|
1066
|
+
tuple[str, str, Callable[[Request], Response], Permission | None, bool, str]
|
|
1067
|
+
] = [
|
|
1068
|
+
("GET", "/auth/login", self.login, None, True, "auth"),
|
|
1069
|
+
("GET", "/auth/callback", self.callback, None, True, "auth"),
|
|
1070
|
+
("GET", "/auth/session", self.session, None, False, "read"),
|
|
1071
|
+
("POST", "/auth/logout", self.logout, None, False, "write"),
|
|
1072
|
+
("GET", "/auth/sessions", self.sessions, None, False, "read"),
|
|
1073
|
+
(
|
|
1074
|
+
"DELETE",
|
|
1075
|
+
"/auth/sessions/{session_id:session}",
|
|
1076
|
+
self.revoke_session,
|
|
1077
|
+
None,
|
|
1078
|
+
False,
|
|
1079
|
+
"write",
|
|
1080
|
+
),
|
|
1081
|
+
("GET", "/dashboard/overview", self.overview, p.REPOSITORIES_READ, False, "read"),
|
|
1082
|
+
("GET", "/organizations", self.organizations, None, False, "read"),
|
|
1083
|
+
(
|
|
1084
|
+
"GET",
|
|
1085
|
+
"/organizations/{organization_id:int}/members",
|
|
1086
|
+
self.list_members,
|
|
1087
|
+
None,
|
|
1088
|
+
False,
|
|
1089
|
+
"read",
|
|
1090
|
+
),
|
|
1091
|
+
(
|
|
1092
|
+
"PUT",
|
|
1093
|
+
"/organizations/{organization_id:int}/members/{user_id:int}",
|
|
1094
|
+
self.put_member,
|
|
1095
|
+
None,
|
|
1096
|
+
False,
|
|
1097
|
+
"write",
|
|
1098
|
+
),
|
|
1099
|
+
(
|
|
1100
|
+
"DELETE",
|
|
1101
|
+
"/organizations/{organization_id:int}/members/{user_id:int}",
|
|
1102
|
+
self.delete_member,
|
|
1103
|
+
None,
|
|
1104
|
+
False,
|
|
1105
|
+
"write",
|
|
1106
|
+
),
|
|
1107
|
+
("GET", "/repositories", self.list_repositories, p.REPOSITORIES_READ, False, "read"),
|
|
1108
|
+
(
|
|
1109
|
+
"GET",
|
|
1110
|
+
"/repositories/{repository_id:int}",
|
|
1111
|
+
self.get_repository,
|
|
1112
|
+
p.REPOSITORIES_READ,
|
|
1113
|
+
False,
|
|
1114
|
+
"read",
|
|
1115
|
+
),
|
|
1116
|
+
(
|
|
1117
|
+
"GET",
|
|
1118
|
+
"/repositories/{repository_id:int}/merge-queue",
|
|
1119
|
+
self.repository_merge_queue,
|
|
1120
|
+
p.REPOSITORIES_READ,
|
|
1121
|
+
False,
|
|
1122
|
+
"read",
|
|
1123
|
+
),
|
|
1124
|
+
(
|
|
1125
|
+
"PUT",
|
|
1126
|
+
"/repositories/{repository_id:int}/monitoring",
|
|
1127
|
+
self.put_monitoring,
|
|
1128
|
+
p.REPOSITORIES_READ,
|
|
1129
|
+
False,
|
|
1130
|
+
"write",
|
|
1131
|
+
),
|
|
1132
|
+
(
|
|
1133
|
+
"POST",
|
|
1134
|
+
"/repositories/{repository_id:int}/enforcement/refresh",
|
|
1135
|
+
self.refresh_enforcement,
|
|
1136
|
+
p.REPOSITORIES_READ,
|
|
1137
|
+
False,
|
|
1138
|
+
"github",
|
|
1139
|
+
),
|
|
1140
|
+
("GET", "/scans", self.list_scans, p.SCANS_READ, False, "read"),
|
|
1141
|
+
("GET", "/scans/{scan_id:hex}", self.get_scan, p.SCANS_READ, False, "read"),
|
|
1142
|
+
(
|
|
1143
|
+
"GET",
|
|
1144
|
+
"/scans/{scan_id:hex}/comparison",
|
|
1145
|
+
self.compare_scan,
|
|
1146
|
+
p.SCANS_READ,
|
|
1147
|
+
False,
|
|
1148
|
+
"read",
|
|
1149
|
+
),
|
|
1150
|
+
(
|
|
1151
|
+
"GET",
|
|
1152
|
+
"/scans/{scan_id:hex}/executions",
|
|
1153
|
+
self.scan_executions,
|
|
1154
|
+
p.SCANS_READ,
|
|
1155
|
+
False,
|
|
1156
|
+
"read",
|
|
1157
|
+
),
|
|
1158
|
+
("POST", "/scans/{scan_id:hex}/rescan", self.rescan, p.SCANS_READ, False, "github"),
|
|
1159
|
+
("GET", "/violations", self.list_violations, p.VIOLATIONS_READ, False, "read"),
|
|
1160
|
+
(
|
|
1161
|
+
"GET",
|
|
1162
|
+
"/violations/{violation_id:hex}",
|
|
1163
|
+
self.get_violation,
|
|
1164
|
+
p.VIOLATIONS_READ,
|
|
1165
|
+
False,
|
|
1166
|
+
"read",
|
|
1167
|
+
),
|
|
1168
|
+
(
|
|
1169
|
+
"PUT",
|
|
1170
|
+
"/violations/{violation_id:hex}/acknowledgement",
|
|
1171
|
+
self.acknowledge,
|
|
1172
|
+
p.VIOLATIONS_READ,
|
|
1173
|
+
False,
|
|
1174
|
+
"write",
|
|
1175
|
+
),
|
|
1176
|
+
(
|
|
1177
|
+
"DELETE",
|
|
1178
|
+
"/violations/{violation_id:hex}/acknowledgement",
|
|
1179
|
+
self.unacknowledge,
|
|
1180
|
+
p.VIOLATIONS_READ,
|
|
1181
|
+
False,
|
|
1182
|
+
"write",
|
|
1183
|
+
),
|
|
1184
|
+
("GET", "/policies", self.list_policies, None, False, "read"),
|
|
1185
|
+
("GET", "/policies/{organization_id:int}", self.get_policy, None, False, "read"),
|
|
1186
|
+
("PUT", "/policies/{organization_id:int}", self.put_policy, None, False, "write"),
|
|
1187
|
+
(
|
|
1188
|
+
"POST",
|
|
1189
|
+
"/policies/{organization_id:int}/preview",
|
|
1190
|
+
self.preview_policy,
|
|
1191
|
+
None,
|
|
1192
|
+
False,
|
|
1193
|
+
"read",
|
|
1194
|
+
),
|
|
1195
|
+
(
|
|
1196
|
+
"GET",
|
|
1197
|
+
"/policies/{organization_id:int}/versions",
|
|
1198
|
+
self.policy_versions,
|
|
1199
|
+
None,
|
|
1200
|
+
False,
|
|
1201
|
+
"read",
|
|
1202
|
+
),
|
|
1203
|
+
(
|
|
1204
|
+
"GET",
|
|
1205
|
+
"/policies/{organization_id:int}/versions/{version:version}",
|
|
1206
|
+
self.policy_version,
|
|
1207
|
+
None,
|
|
1208
|
+
False,
|
|
1209
|
+
"read",
|
|
1210
|
+
),
|
|
1211
|
+
(
|
|
1212
|
+
"GET",
|
|
1213
|
+
"/policies/{organization_id:int}/diff",
|
|
1214
|
+
self.policy_diff,
|
|
1215
|
+
None,
|
|
1216
|
+
False,
|
|
1217
|
+
"read",
|
|
1218
|
+
),
|
|
1219
|
+
(
|
|
1220
|
+
"POST",
|
|
1221
|
+
"/policies/{organization_id:int}/rollback",
|
|
1222
|
+
self.rollback_policy,
|
|
1223
|
+
None,
|
|
1224
|
+
False,
|
|
1225
|
+
"sensitive",
|
|
1226
|
+
),
|
|
1227
|
+
("GET", "/rules", self.list_rules, p.RULES_READ, False, "read"),
|
|
1228
|
+
("GET", "/rules/{rule_id:ident}", self.get_rule, p.RULES_READ, False, "read"),
|
|
1229
|
+
("GET", "/audit", self.list_audit, p.AUDIT_READ, False, "read"),
|
|
1230
|
+
("GET", "/audit/{event_id:hex}", self.get_audit_event, p.AUDIT_READ, False, "read"),
|
|
1231
|
+
(
|
|
1232
|
+
"GET",
|
|
1233
|
+
"/github/installations",
|
|
1234
|
+
self.list_installations,
|
|
1235
|
+
p.REPOSITORIES_READ,
|
|
1236
|
+
False,
|
|
1237
|
+
"read",
|
|
1238
|
+
),
|
|
1239
|
+
(
|
|
1240
|
+
"GET",
|
|
1241
|
+
"/github/installations/{installation_id:int}",
|
|
1242
|
+
self.get_installation,
|
|
1243
|
+
p.REPOSITORIES_READ,
|
|
1244
|
+
False,
|
|
1245
|
+
"read",
|
|
1246
|
+
),
|
|
1247
|
+
(
|
|
1248
|
+
"GET",
|
|
1249
|
+
"/github/installations/{installation_id:int}/repositories",
|
|
1250
|
+
self.installation_repositories,
|
|
1251
|
+
p.REPOSITORIES_READ,
|
|
1252
|
+
False,
|
|
1253
|
+
"read",
|
|
1254
|
+
),
|
|
1255
|
+
(
|
|
1256
|
+
"POST",
|
|
1257
|
+
"/github/installations/{installation_id:int}/sync",
|
|
1258
|
+
self.sync_installation,
|
|
1259
|
+
p.REPOSITORIES_READ,
|
|
1260
|
+
False,
|
|
1261
|
+
"github",
|
|
1262
|
+
),
|
|
1263
|
+
# Notifications are scoped per user inside the notification center.
|
|
1264
|
+
("GET", "/notifications", self.list_notifications, None, False, "read"),
|
|
1265
|
+
("GET", "/notifications/counts", self.notification_counts, None, False, "read"),
|
|
1266
|
+
(
|
|
1267
|
+
"POST",
|
|
1268
|
+
"/notifications/read-all",
|
|
1269
|
+
self.read_all_notifications,
|
|
1270
|
+
None,
|
|
1271
|
+
False,
|
|
1272
|
+
"write",
|
|
1273
|
+
),
|
|
1274
|
+
(
|
|
1275
|
+
"GET",
|
|
1276
|
+
"/notifications/{notification_id:hex}",
|
|
1277
|
+
self.get_notification,
|
|
1278
|
+
None,
|
|
1279
|
+
False,
|
|
1280
|
+
"read",
|
|
1281
|
+
),
|
|
1282
|
+
(
|
|
1283
|
+
"POST",
|
|
1284
|
+
"/notifications/{notification_id:hex}/read",
|
|
1285
|
+
self.read_notification,
|
|
1286
|
+
None,
|
|
1287
|
+
False,
|
|
1288
|
+
"write",
|
|
1289
|
+
),
|
|
1290
|
+
(
|
|
1291
|
+
"POST",
|
|
1292
|
+
"/notifications/{notification_id:hex}/unread",
|
|
1293
|
+
self.unread_notification,
|
|
1294
|
+
None,
|
|
1295
|
+
False,
|
|
1296
|
+
"write",
|
|
1297
|
+
),
|
|
1298
|
+
(
|
|
1299
|
+
"POST",
|
|
1300
|
+
"/notifications/{notification_id:hex}/archive",
|
|
1301
|
+
self.archive_notification,
|
|
1302
|
+
None,
|
|
1303
|
+
False,
|
|
1304
|
+
"write",
|
|
1305
|
+
),
|
|
1306
|
+
(
|
|
1307
|
+
"GET",
|
|
1308
|
+
"/notification-preferences",
|
|
1309
|
+
self.notification_preferences,
|
|
1310
|
+
None,
|
|
1311
|
+
False,
|
|
1312
|
+
"read",
|
|
1313
|
+
),
|
|
1314
|
+
(
|
|
1315
|
+
"PATCH",
|
|
1316
|
+
"/notification-preferences",
|
|
1317
|
+
self.patch_notification_preferences,
|
|
1318
|
+
None,
|
|
1319
|
+
False,
|
|
1320
|
+
"write",
|
|
1321
|
+
),
|
|
1322
|
+
(
|
|
1323
|
+
"GET",
|
|
1324
|
+
"/organizations/{organization_id:int}/notification-settings",
|
|
1325
|
+
self.notification_settings,
|
|
1326
|
+
None,
|
|
1327
|
+
False,
|
|
1328
|
+
"read",
|
|
1329
|
+
),
|
|
1330
|
+
(
|
|
1331
|
+
"PUT",
|
|
1332
|
+
"/organizations/{organization_id:int}/notification-settings",
|
|
1333
|
+
self.put_notification_settings,
|
|
1334
|
+
None,
|
|
1335
|
+
False,
|
|
1336
|
+
"sensitive",
|
|
1337
|
+
),
|
|
1338
|
+
(
|
|
1339
|
+
"POST",
|
|
1340
|
+
"/organizations/{organization_id:int}/notification-webhooks",
|
|
1341
|
+
self.add_notification_webhook,
|
|
1342
|
+
None,
|
|
1343
|
+
False,
|
|
1344
|
+
"sensitive",
|
|
1345
|
+
),
|
|
1346
|
+
(
|
|
1347
|
+
"DELETE",
|
|
1348
|
+
"/organizations/{organization_id:int}/notification-webhooks/{endpoint_id:hex}",
|
|
1349
|
+
self.delete_notification_webhook,
|
|
1350
|
+
None,
|
|
1351
|
+
False,
|
|
1352
|
+
"sensitive",
|
|
1353
|
+
),
|
|
1354
|
+
(
|
|
1355
|
+
"GET",
|
|
1356
|
+
"/organizations/{organization_id:int}/notification-deliveries",
|
|
1357
|
+
self.notification_deliveries,
|
|
1358
|
+
None,
|
|
1359
|
+
False,
|
|
1360
|
+
"read",
|
|
1361
|
+
),
|
|
1362
|
+
]
|
|
1363
|
+
if self._governance is not None:
|
|
1364
|
+
table.extend(GovernanceRoutes(self._governance).routes())
|
|
1365
|
+
routes = []
|
|
1366
|
+
for method, template, handler, permission, public, rate in table:
|
|
1367
|
+
full = API_PREFIX + template
|
|
1368
|
+
pattern, converters = _compile(full)
|
|
1369
|
+
routes.append(
|
|
1370
|
+
Route(method, full, handler, permission, public, rate, pattern, converters)
|
|
1371
|
+
)
|
|
1372
|
+
return tuple(routes)
|
|
1373
|
+
|
|
1374
|
+
@property
|
|
1375
|
+
def routes(self) -> tuple[Route, ...]:
|
|
1376
|
+
return self._routes
|