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.
Files changed (197) hide show
  1. commitguard/__init__.py +26 -0
  2. commitguard/__main__.py +6 -0
  3. commitguard/api/__init__.py +18 -0
  4. commitguard/api/app.py +1376 -0
  5. commitguard/api/governance.py +1085 -0
  6. commitguard/api/hosting.py +196 -0
  7. commitguard/api/http.py +252 -0
  8. commitguard/api/settings.py +169 -0
  9. commitguard/audit/__init__.py +13 -0
  10. commitguard/audit/logger.py +34 -0
  11. commitguard/audit/models.py +222 -0
  12. commitguard/audit/storage.py +59 -0
  13. commitguard/ci/__init__.py +7 -0
  14. commitguard/ci/context.py +60 -0
  15. commitguard/cli/__init__.py +6 -0
  16. commitguard/cli/app.py +74 -0
  17. commitguard/cli/commands/__init__.py +1 -0
  18. commitguard/cli/commands/benchmark.py +441 -0
  19. commitguard/cli/commands/check.py +100 -0
  20. commitguard/cli/commands/ci.py +165 -0
  21. commitguard/cli/commands/dashboard.py +141 -0
  22. commitguard/cli/commands/doctor.py +533 -0
  23. commitguard/cli/commands/github.py +449 -0
  24. commitguard/cli/commands/hook.py +156 -0
  25. commitguard/cli/commands/init.py +137 -0
  26. commitguard/cli/commands/install.py +152 -0
  27. commitguard/cli/commands/policy.py +36 -0
  28. commitguard/cli/commands/report.py +39 -0
  29. commitguard/cli/commands/reproduce.py +123 -0
  30. commitguard/cli/commands/scan.py +47 -0
  31. commitguard/cli/common.py +44 -0
  32. commitguard/cli/output.py +89 -0
  33. commitguard/cli/render.py +367 -0
  34. commitguard/config/__init__.py +6 -0
  35. commitguard/config/defaults.py +53 -0
  36. commitguard/config/enforcement.py +53 -0
  37. commitguard/config/loader.py +174 -0
  38. commitguard/config/schema.py +105 -0
  39. commitguard/config/sources.py +183 -0
  40. commitguard/controlplane/__init__.py +24 -0
  41. commitguard/controlplane/access.py +231 -0
  42. commitguard/controlplane/commands.py +393 -0
  43. commitguard/controlplane/errors.py +88 -0
  44. commitguard/controlplane/identity.py +478 -0
  45. commitguard/controlplane/members.py +219 -0
  46. commitguard/controlplane/notifications.py +787 -0
  47. commitguard/controlplane/pagination.py +146 -0
  48. commitguard/controlplane/policies.py +1204 -0
  49. commitguard/controlplane/queries.py +1814 -0
  50. commitguard/controlplane/results.py +909 -0
  51. commitguard/controlplane/rules.py +184 -0
  52. commitguard/controlplane/views.py +799 -0
  53. commitguard/core/__init__.py +6 -0
  54. commitguard/core/context.py +31 -0
  55. commitguard/core/decision.py +58 -0
  56. commitguard/core/engine.py +82 -0
  57. commitguard/core/result.py +177 -0
  58. commitguard/detectors/__init__.py +6 -0
  59. commitguard/detectors/base.py +58 -0
  60. commitguard/detectors/bot.py +87 -0
  61. commitguard/detectors/coauthor.py +86 -0
  62. commitguard/detectors/identity.py +76 -0
  63. commitguard/detectors/registry.py +72 -0
  64. commitguard/detectors/trailer.py +211 -0
  65. commitguard/exceptions/__init__.py +33 -0
  66. commitguard/exceptions/base.py +9 -0
  67. commitguard/exceptions/configuration.py +22 -0
  68. commitguard/exceptions/detection.py +11 -0
  69. commitguard/exceptions/git.py +41 -0
  70. commitguard/exceptions/service.py +25 -0
  71. commitguard/git/__init__.py +12 -0
  72. commitguard/git/commands.py +101 -0
  73. commitguard/git/commit.py +97 -0
  74. commitguard/git/diff.py +36 -0
  75. commitguard/git/hooks.py +527 -0
  76. commitguard/git/push.py +93 -0
  77. commitguard/git/ranges.py +71 -0
  78. commitguard/git/repository.py +447 -0
  79. commitguard/github/__init__.py +34 -0
  80. commitguard/github/actions.py +163 -0
  81. commitguard/github/app.py +935 -0
  82. commitguard/github/auth.py +217 -0
  83. commitguard/github/check_runs.py +172 -0
  84. commitguard/github/checks.py +210 -0
  85. commitguard/github/client.py +844 -0
  86. commitguard/github/enforcement_status.py +209 -0
  87. commitguard/github/errors.py +129 -0
  88. commitguard/github/events.py +563 -0
  89. commitguard/github/identifiers.py +90 -0
  90. commitguard/github/installations.py +566 -0
  91. commitguard/github/markdown.py +19 -0
  92. commitguard/github/permissions.py +70 -0
  93. commitguard/github/pull_requests.py +53 -0
  94. commitguard/github/queue.py +47 -0
  95. commitguard/github/recovery.py +124 -0
  96. commitguard/github/repositories.py +305 -0
  97. commitguard/github/server.py +52 -0
  98. commitguard/github/settings.py +174 -0
  99. commitguard/github/storage.py +2315 -0
  100. commitguard/github/webhooks.py +129 -0
  101. commitguard/github/worker.py +628 -0
  102. commitguard/github/workflow.py +286 -0
  103. commitguard/governance/__init__.py +26 -0
  104. commitguard/governance/bulk.py +765 -0
  105. commitguard/governance/cache.py +88 -0
  106. commitguard/governance/common.py +216 -0
  107. commitguard/governance/exceptions.py +861 -0
  108. commitguard/governance/groups.py +448 -0
  109. commitguard/governance/inventory.py +386 -0
  110. commitguard/governance/posture.py +1272 -0
  111. commitguard/governance/resolver.py +632 -0
  112. commitguard/governance/rollouts.py +760 -0
  113. commitguard/governance/rules.py +371 -0
  114. commitguard/governance/schedules.py +663 -0
  115. commitguard/governance/service.py +120 -0
  116. commitguard/governance/settings.py +365 -0
  117. commitguard/governance/simulation.py +618 -0
  118. commitguard/governance/workflow.py +734 -0
  119. commitguard/notifications/__init__.py +2 -0
  120. commitguard/notifications/channels/__init__.py +1 -0
  121. commitguard/notifications/channels/base.py +22 -0
  122. commitguard/notifications/channels/email.py +110 -0
  123. commitguard/notifications/channels/in_app.py +74 -0
  124. commitguard/notifications/channels/sink.py +58 -0
  125. commitguard/notifications/channels/webhook.py +233 -0
  126. commitguard/notifications/deduplication.py +57 -0
  127. commitguard/notifications/dispatcher.py +201 -0
  128. commitguard/notifications/models.py +439 -0
  129. commitguard/notifications/outbox.py +106 -0
  130. commitguard/notifications/preferences.py +224 -0
  131. commitguard/notifications/retry.py +282 -0
  132. commitguard/notifications/service.py +128 -0
  133. commitguard/notifications/settings.py +167 -0
  134. commitguard/notifications/templates.py +108 -0
  135. commitguard/observability/__init__.py +5 -0
  136. commitguard/observability/logging.py +161 -0
  137. commitguard/observability/metrics.py +105 -0
  138. commitguard/policies/__init__.py +6 -0
  139. commitguard/policies/defaults.py +48 -0
  140. commitguard/policies/evaluator.py +66 -0
  141. commitguard/policies/governance.py +498 -0
  142. commitguard/policies/loader.py +23 -0
  143. commitguard/policies/mandatory.py +52 -0
  144. commitguard/policies/model.py +46 -0
  145. commitguard/provenance/__init__.py +9 -0
  146. commitguard/provenance/author.py +146 -0
  147. commitguard/provenance/committer.py +16 -0
  148. commitguard/provenance/normalization.py +158 -0
  149. commitguard/provenance/signatures.py +34 -0
  150. commitguard/provenance/trailers.py +256 -0
  151. commitguard/research/__init__.py +26 -0
  152. commitguard/research/compare.py +231 -0
  153. commitguard/research/datasets.py +1484 -0
  154. commitguard/research/detection.py +183 -0
  155. commitguard/research/environment.py +185 -0
  156. commitguard/research/gitenv.py +108 -0
  157. commitguard/research/hooks.py +247 -0
  158. commitguard/research/metrics.py +85 -0
  159. commitguard/research/performance.py +194 -0
  160. commitguard/research/platform.py +288 -0
  161. commitguard/research/report.py +372 -0
  162. commitguard/research/repository.py +111 -0
  163. commitguard/research/reproduction.py +297 -0
  164. commitguard/research/results.py +94 -0
  165. commitguard/rules/__init__.py +11 -0
  166. commitguard/rules/data/ai-domains.yaml +51 -0
  167. commitguard/rules/data/ai-identities.yaml +131 -0
  168. commitguard/rules/data/bot-identities.yaml +53 -0
  169. commitguard/rules/data/patterns.yaml +52 -0
  170. commitguard/rules/loader.py +102 -0
  171. commitguard/rules/matcher.py +212 -0
  172. commitguard/rules/models.py +269 -0
  173. commitguard/security/__init__.py +5 -0
  174. commitguard/security/hashing.py +30 -0
  175. commitguard/security/rate_limit.py +33 -0
  176. commitguard/security/safe_yaml.py +69 -0
  177. commitguard/security/sanitization.py +85 -0
  178. commitguard/security/secrets.py +169 -0
  179. commitguard/security/validation.py +89 -0
  180. commitguard/services/__init__.py +15 -0
  181. commitguard/services/analysis.py +119 -0
  182. commitguard/services/audit.py +95 -0
  183. commitguard/services/ci.py +383 -0
  184. commitguard/services/enforcement.py +102 -0
  185. commitguard/services/hooks.py +254 -0
  186. commitguard/services/remediation.py +99 -0
  187. commitguard/services/reports.py +146 -0
  188. commitguard/services/scan.py +172 -0
  189. commitguard/utils/__init__.py +1 -0
  190. commitguard/utils/filesystem.py +72 -0
  191. commitguard/utils/platform.py +35 -0
  192. commitguard/utils/subprocess.py +84 -0
  193. commitguardian-0.1.0.dist-info/METADATA +694 -0
  194. commitguardian-0.1.0.dist-info/RECORD +197 -0
  195. commitguardian-0.1.0.dist-info/WHEEL +4 -0
  196. commitguardian-0.1.0.dist-info/entry_points.txt +2 -0
  197. 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