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
@@ -0,0 +1,1085 @@
1
+ """The ``/api/v1`` routes of organization governance.
2
+
3
+ Every route is authenticated and CSRF-checked by :class:`~commitguard.api.app.DashboardApi`.
4
+ Authorization happens in the governance services, per resource: the caller must
5
+ be a member of the resource's organization (otherwise the answer is 404, exactly
6
+ like a missing resource) and hold the permission the operation needs
7
+ (otherwise 403). Nested resources are resolved from the stored row - a group's,
8
+ draft's or exception's organization is never taken from the URL or the body.
9
+
10
+ ====================================================== ==================================
11
+ Route Permission (checked in services)
12
+ ====================================================== ==================================
13
+ GET /organizations/{id} organization:read / security:read
14
+ GET PUT /organizations/{id}/settings organization:read / :manage
15
+ GET /organizations/{id}/security/overview|repositories security:read
16
+ GET /organizations/{id}/security/policies|exceptions policies:read / exceptions:read
17
+ GET /organizations/{id}/security/trends|events security:read
18
+ POST /organizations/{id}/security/events/{e}/acknowledge violations:manage
19
+ GET /organizations/{id}/reports/{kind} security:read (policy_changes: audit)
20
+ GET /organizations/{id}/search organization:read (+ per kind)
21
+ POST /organizations/{id}/repositories/onboard|mode repositories:manage
22
+ GET /repositories/{r}/effective-policy policies:read
23
+ GET POST /organizations/{id}/repository-groups repositories:read / :manage
24
+ GET PATCH DELETE /repository-groups/{g} repositories:read / :manage
25
+ POST /repository-groups/{g}/repositories[/remove] repositories:manage
26
+ GET /organizations/{id}/policies policies:read
27
+ GET /policy-targets/{type}/{id}/versions policies:read
28
+ POST /policy-targets/{type}/{id}/rollback policies:rollback
29
+ GET POST /organizations/{id}/policy-drafts policies:read / :write
30
+ GET PATCH /policy-drafts/{d} policies:read / :write
31
+ POST /policy-drafts/{d}/submit|cancel policies:write
32
+ POST /policy-drafts/{d}/approve|reject policies:approve (not the author)
33
+ POST /policy-drafts/{d}/publish policies:publish
34
+ POST /policy-drafts/{d}/emergency-publish policies:emergency
35
+ POST /policy-drafts/{d}/simulations policies:write
36
+ GET /organizations/{id}/simulations, /simulations/{s} policies:read
37
+ GET /organizations/{id}/rollouts, /rollouts/{r} policies:read
38
+ POST /rollouts/{r}/advance|pause|resume policies:publish
39
+ POST /rollouts/{r}/rollback policies:rollback
40
+ GET /organizations/{id}/policy-propagation policies:read
41
+ GET POST /organizations/{id}/exceptions exceptions:read / :create
42
+ GET /exceptions/{x} exceptions:read
43
+ POST /exceptions/{x}/approve|reject exceptions:approve (not the requester)
44
+ POST /exceptions/{x}/revoke exceptions:revoke
45
+ POST /exceptions/{x}/cancel requester or exceptions:revoke
46
+ GET PUT /organizations/{id}/rules, .../rules/history rules:read / rules:manage
47
+ GET POST /organizations/{id}/bulk-operations repositories:read / :manage
48
+ GET /bulk-operations/{b}; POST .../cancel|retry repositories:read / :manage
49
+ GET POST /organizations/{id}/scan-schedules security:read / :manage
50
+ GET PATCH /scan-schedules/{s}; POST .../disable security:read / :manage
51
+ ====================================================== ==================================
52
+
53
+ Security exceptions are never deleted through the API: they are revoked,
54
+ rejected, cancelled or expire, and the history is kept. Groups are archived.
55
+ """
56
+
57
+ from collections.abc import Callable
58
+ from typing import Any
59
+
60
+ from commitguard.api.http import ApiError, Request, Response, bad_request, ok
61
+ from commitguard.audit.models import Actor
62
+ from commitguard.controlplane.access import Permission, Principal
63
+ from commitguard.controlplane.errors import NotFoundError
64
+ from commitguard.controlplane.pagination import encode_cursor, offset_cursor, parse_limit
65
+ from commitguard.controlplane.policies import (
66
+ ORGANIZATION_TARGET,
67
+ PolicyTarget,
68
+ PolicyTargetType,
69
+ canonical_document,
70
+ )
71
+ from commitguard.controlplane.views import OrganizationRef
72
+ from commitguard.governance.common import (
73
+ account_repository,
74
+ repository_ids,
75
+ require,
76
+ visible_repository_ids,
77
+ )
78
+ from commitguard.governance.rollouts import parse_stages
79
+ from commitguard.governance.service import GovernanceServices
80
+ from commitguard.governance.workflow import parse_rules, parse_target
81
+
82
+ type RouteRow = tuple[str, str, Callable[[Request], Response], Permission | None, bool, str]
83
+
84
+
85
+ def _principal(request: Request) -> Principal:
86
+ if request.principal is None: # pragma: no cover - guaranteed by the dispatcher
87
+ raise ApiError(401, "UNAUTHENTICATED", "Sign in to continue.")
88
+ return request.principal
89
+
90
+
91
+ def _flag(value: object, field: str, default: bool = False) -> bool:
92
+ if value is None:
93
+ return default
94
+ if not isinstance(value, bool):
95
+ raise bad_request(f"{field} must be true or false", field)
96
+ return value
97
+
98
+
99
+ def _optional_ids(value: object, field: str) -> list[int] | None:
100
+ return None if value is None else repository_ids(value, field)
101
+
102
+
103
+ class GovernanceRoutes:
104
+ def __init__(self, governance: GovernanceServices) -> None:
105
+ self.g = governance
106
+
107
+ # -- organization ----------------------------------------------------- #
108
+ def organization(self, request: Request) -> Response:
109
+ principal = _principal(request)
110
+ account_id = request.params["organization_id"]
111
+ require(principal, Permission.ORGANIZATION_READ, account_id)
112
+ overview = self.g.posture.overview(principal, account_id)
113
+ settings = self.g.settings.view(principal, account_id)
114
+ return ok({"organization": overview, "settings": settings})
115
+
116
+ def get_settings(self, request: Request) -> Response:
117
+ principal = _principal(request)
118
+ return ok(self.g.settings.view(principal, request.params["organization_id"]))
119
+
120
+ def put_settings(self, request: Request) -> Response:
121
+ principal = _principal(request)
122
+ body = request.json()
123
+ view = self.g.settings.update(
124
+ principal,
125
+ request.params["organization_id"],
126
+ expected_version=body.get("expected_version"),
127
+ changes=body.get("settings"),
128
+ reason=body.get("reason"),
129
+ confirm=body.get("confirm", False),
130
+ )
131
+ return ok(view)
132
+
133
+ def security_overview(self, request: Request) -> Response:
134
+ return ok(self.g.posture.overview(_principal(request), request.params["organization_id"]))
135
+
136
+ def security_repositories(self, request: Request) -> Response:
137
+ principal = _principal(request)
138
+ offset = offset_cursor(request.arg("cursor"))
139
+ limit = parse_limit(request.arg("limit"))
140
+ names = (
141
+ "q",
142
+ "group",
143
+ "posture",
144
+ "protection",
145
+ "mode",
146
+ "onboarding",
147
+ "policy_state",
148
+ "drift",
149
+ "exceptions",
150
+ "severity",
151
+ "last_scan",
152
+ "sort",
153
+ )
154
+ page = self.g.posture.matrix(
155
+ principal,
156
+ request.params["organization_id"],
157
+ filters={name: request.arg(name) for name in names},
158
+ offset=offset,
159
+ limit=limit,
160
+ )
161
+ return ok(
162
+ list(page.items),
163
+ {
164
+ "next_cursor": page.next_cursor,
165
+ "limit": limit,
166
+ "total": page.total,
167
+ "computed_at": page.computed_at.isoformat(),
168
+ },
169
+ )
170
+
171
+ def security_policies(self, request: Request) -> Response:
172
+ principal = _principal(request)
173
+ account_id = request.params["organization_id"]
174
+ require(principal, Permission.POLICIES_READ, account_id)
175
+ return ok(
176
+ {
177
+ "targets": self._policy_targets(principal, account_id),
178
+ "drafts": self.g.workflow.list_drafts(principal, account_id),
179
+ "rollouts": self.g.rollouts.list_rollouts(principal, account_id),
180
+ "propagation": self.g.resolver.propagation_status(principal, account_id),
181
+ }
182
+ )
183
+
184
+ def security_exceptions(self, request: Request) -> Response:
185
+ principal = _principal(request)
186
+ account_id = request.params["organization_id"]
187
+ items, _ = self.g.exceptions.list(principal, account_id, limit=500)
188
+ counts: dict[str, int] = {}
189
+ for item in items:
190
+ counts[item.status] = counts.get(item.status, 0) + 1
191
+ counts["expiring_soon"] = sum(1 for item in items if item.expiring_soon)
192
+ return ok({"counts": counts, "exceptions": items})
193
+
194
+ def security_trends(self, request: Request) -> Response:
195
+ raw = request.arg("days") or "30"
196
+ if not raw.isdigit() or len(raw) > 3:
197
+ raise bad_request("days must be a number", "days")
198
+ return ok(
199
+ self.g.posture.trends(
200
+ _principal(request), request.params["organization_id"], days=int(raw)
201
+ )
202
+ )
203
+
204
+ def security_events(self, request: Request) -> Response:
205
+ return ok(
206
+ self.g.posture.security_events(_principal(request), request.params["organization_id"])
207
+ )
208
+
209
+ def acknowledge_event(self, request: Request) -> Response:
210
+ body = request.json()
211
+ return ok(
212
+ self.g.posture.acknowledge_event(
213
+ _principal(request),
214
+ request.params["organization_id"],
215
+ request.params["event_id"],
216
+ body.get("note"),
217
+ )
218
+ )
219
+
220
+ def report(self, request: Request) -> Response:
221
+ principal = _principal(request)
222
+ content_type, body, filename = self.g.posture.report(
223
+ principal,
224
+ request.params["organization_id"],
225
+ kind=request.params["kind"],
226
+ fmt=request.arg("format") or "json",
227
+ )
228
+ return Response(
229
+ 200,
230
+ body,
231
+ [
232
+ ("Content-Type", content_type),
233
+ ("Content-Disposition", f'attachment; filename="{filename}"'),
234
+ ],
235
+ )
236
+
237
+ def search(self, request: Request) -> Response:
238
+ results = self.g.posture.search(
239
+ _principal(request), request.params["organization_id"], request.arg("q") or ""
240
+ )
241
+ return ok(results)
242
+
243
+ # -- repositories ----------------------------------------------------- #
244
+ def onboard(self, request: Request) -> Response:
245
+ require(
246
+ _principal(request), Permission.REPOSITORIES_MANAGE, request.params["organization_id"]
247
+ )
248
+ body = request.json()
249
+ changed = self.g.inventory.onboard(
250
+ _principal(request),
251
+ request.params["organization_id"],
252
+ repository_ids(body.get("repository_ids")),
253
+ mode=body.get("mode"),
254
+ confirm=body.get("confirm", False),
255
+ reason=body.get("reason"),
256
+ )
257
+ return ok({"changed": changed})
258
+
259
+ def set_mode(self, request: Request) -> Response:
260
+ require(
261
+ _principal(request), Permission.REPOSITORIES_MANAGE, request.params["organization_id"]
262
+ )
263
+ body = request.json()
264
+ changed = self.g.inventory.set_mode(
265
+ _principal(request),
266
+ request.params["organization_id"],
267
+ repository_ids(body.get("repository_ids")),
268
+ mode=body.get("mode"),
269
+ confirm=body.get("confirm", False),
270
+ reason=body.get("reason"),
271
+ )
272
+ return ok({"changed": changed})
273
+
274
+ def _repository_account(self, principal: Principal, repository_id: int) -> int:
275
+ """The organization of a repository the caller can see (else 404)."""
276
+ for account_id in principal.accounts_with(Permission.REPOSITORIES_READ):
277
+ if repository_id in visible_repository_ids(
278
+ self.g.store, principal, account_id
279
+ ) and account_repository(self.g.store, account_id, repository_id):
280
+ return account_id
281
+ raise NotFoundError()
282
+
283
+ def effective_policy(self, request: Request) -> Response:
284
+ principal = _principal(request)
285
+ repository_id = request.params["repository_id"]
286
+ account_id = self._repository_account(principal, repository_id)
287
+ view = self.g.resolver.effective_view(principal, account_id, repository_id)
288
+ active, soon = self.g.exceptions.counts(
289
+ account_id, repository_id, [g for g in view.versions.groups]
290
+ )
291
+ return ok(view, {"exceptions": {"active": active, "expiring_soon": soon}})
292
+
293
+ # -- groups ----------------------------------------------------------- #
294
+ def list_groups(self, request: Request) -> Response:
295
+ archived = request.arg("archived") == "true"
296
+ return ok(
297
+ self.g.groups.list_groups(
298
+ _principal(request), request.params["organization_id"], include_archived=archived
299
+ )
300
+ )
301
+
302
+ def create_group(self, request: Request) -> Response:
303
+ body = request.json()
304
+ view = self.g.groups.create(
305
+ _principal(request),
306
+ request.params["organization_id"],
307
+ name=body.get("name"),
308
+ description=body.get("description"),
309
+ )
310
+ return ok(view, status=201)
311
+
312
+ def get_group(self, request: Request) -> Response:
313
+ return ok(self.g.groups.get(_principal(request), request.params["group_id"]))
314
+
315
+ def patch_group(self, request: Request) -> Response:
316
+ body = request.json()
317
+ return ok(
318
+ self.g.groups.update(
319
+ _principal(request),
320
+ request.params["group_id"],
321
+ name=body.get("name"),
322
+ description=body.get("description"),
323
+ )
324
+ )
325
+
326
+ def archive_group(self, request: Request) -> Response:
327
+ confirm = request.arg("confirm") == "true"
328
+ self.g.groups.archive(_principal(request), request.params["group_id"], confirm=confirm)
329
+ return ok({"archived": True})
330
+
331
+ def add_group_members(self, request: Request) -> Response:
332
+ self.g.groups.account_of(
333
+ _principal(request), request.params["group_id"], Permission.REPOSITORIES_MANAGE
334
+ )
335
+ body = request.json()
336
+ return ok(
337
+ self.g.groups.add_members(
338
+ _principal(request),
339
+ request.params["group_id"],
340
+ repository_ids(body.get("repository_ids")),
341
+ )
342
+ )
343
+
344
+ def remove_group_members(self, request: Request) -> Response:
345
+ self.g.groups.account_of(
346
+ _principal(request), request.params["group_id"], Permission.REPOSITORIES_MANAGE
347
+ )
348
+ body = request.json()
349
+ return ok(
350
+ self.g.groups.remove_members(
351
+ _principal(request),
352
+ request.params["group_id"],
353
+ repository_ids(body.get("repository_ids")),
354
+ )
355
+ )
356
+
357
+ # -- policies --------------------------------------------------------- #
358
+ def _organization_ref(self, principal: Principal, account_id: int) -> OrganizationRef:
359
+ membership = principal.memberships.get(account_id)
360
+ if membership is None:
361
+ raise NotFoundError()
362
+ return OrganizationRef(
363
+ id=account_id, login=membership.account_login, type=membership.account_type
364
+ )
365
+
366
+ def _policy_targets(self, principal: Principal, account_id: int) -> dict[str, Any]:
367
+ organization = self._organization_ref(principal, account_id)
368
+ can_write = principal.can(Permission.POLICIES_WRITE, account_id)
369
+ groups = self.g.groups.list_groups(principal, account_id)
370
+ visible = visible_repository_ids(self.g.store, principal, account_id)
371
+ repository_targets = [
372
+ repository_id
373
+ for repository_id in self.g.policies.repository_targets(account_id)
374
+ if repository_id in visible
375
+ ]
376
+ return {
377
+ "organization": self.g.policies.view(organization, can_write=can_write),
378
+ "groups": [
379
+ self.g.policies.scoped_view(
380
+ organization, PolicyTarget.group(group.id), can_write=can_write
381
+ )
382
+ for group in groups
383
+ ],
384
+ "repositories": [
385
+ self.g.policies.scoped_view(
386
+ organization, PolicyTarget.repository(repository_id), can_write=can_write
387
+ )
388
+ for repository_id in repository_targets
389
+ ],
390
+ }
391
+
392
+ def list_policy_targets(self, request: Request) -> Response:
393
+ principal = _principal(request)
394
+ account_id = request.params["organization_id"]
395
+ require(principal, Permission.POLICIES_READ, account_id)
396
+ return ok(self._policy_targets(principal, account_id))
397
+
398
+ def _target(
399
+ self,
400
+ request: Request,
401
+ principal: Principal,
402
+ permission: Permission = Permission.POLICIES_READ,
403
+ ) -> tuple[int, PolicyTarget]:
404
+ account_id = request.params["organization_id"]
405
+ require(principal, permission, account_id) # before parsing: no validation oracle
406
+ target = parse_target(request.params["target_type"], request.arg("target_id") or "")
407
+ if target.type is PolicyTargetType.REPOSITORY and int(target.id) not in (
408
+ visible_repository_ids(self.g.store, principal, account_id)
409
+ ):
410
+ raise NotFoundError()
411
+ if self.g.policies.target_label(account_id, target) is None:
412
+ raise NotFoundError()
413
+ return account_id, target
414
+
415
+ def policy_target_versions(self, request: Request) -> Response:
416
+ principal = _principal(request)
417
+ account_id, target = self._target(request, principal)
418
+ offset = offset_cursor(request.arg("cursor"))
419
+ limit = parse_limit(request.arg("limit"))
420
+ versions = self.g.policies.versions(
421
+ account_id, offset=offset, limit=limit + 1, target=target
422
+ )
423
+ next_cursor = encode_cursor([offset + limit]) if len(versions) > limit else None
424
+ organization = self._organization_ref(principal, account_id)
425
+ view = (
426
+ self.g.policies.scoped_view(
427
+ organization, target, can_write=principal.can(Permission.POLICIES_WRITE, account_id)
428
+ )
429
+ if target.scoped
430
+ else self.g.policies.view(
431
+ organization, can_write=principal.can(Permission.POLICIES_WRITE, account_id)
432
+ )
433
+ )
434
+ return ok(
435
+ {"policy": view, "versions": versions[:limit]},
436
+ {"next_cursor": next_cursor, "limit": limit},
437
+ )
438
+
439
+ def policy_target_rollback(self, request: Request) -> Response:
440
+ principal = _principal(request)
441
+ account_id, target = self._target(request, principal, Permission.POLICIES_ROLLBACK)
442
+ body = request.json()
443
+ target_version = body.get("target_version")
444
+ expected = body.get("expected_current_version")
445
+ checks = (("target_version", target_version), ("expected_current_version", expected))
446
+ for name, value in checks:
447
+ if not isinstance(value, int) or isinstance(value, bool) or value < 0:
448
+ raise bad_request(f"{name} must be a non-negative integer", name)
449
+ created, diff = self.g.policies.rollback(
450
+ account_id=account_id,
451
+ actor=Actor.user(principal.user_id, principal.login),
452
+ authenticated_at=principal.authenticated_at,
453
+ target_version=int(target_version), # type: ignore[arg-type]
454
+ expected_current_version=int(expected), # type: ignore[arg-type]
455
+ reason=body.get("reason") if isinstance(body.get("reason"), str) else None,
456
+ confirm=_flag(body.get("confirm"), "confirm"),
457
+ target=target,
458
+ )
459
+ return ok({"version": created.version, "restored_version": target_version, "diff": diff})
460
+
461
+ # -- drafts ----------------------------------------------------------- #
462
+ def list_drafts(self, request: Request) -> Response:
463
+ return ok(
464
+ self.g.workflow.list_drafts(
465
+ _principal(request), request.params["organization_id"], state=request.arg("state")
466
+ )
467
+ )
468
+
469
+ def create_draft(self, request: Request) -> Response:
470
+ require(_principal(request), Permission.POLICIES_WRITE, request.params["organization_id"])
471
+ body = request.json()
472
+ floors, defaults = parse_rules(body)
473
+ view = self.g.workflow.create(
474
+ _principal(request),
475
+ request.params["organization_id"],
476
+ target_type=body.get("target_type"),
477
+ target_id=body.get("target_id"),
478
+ floors=floors,
479
+ defaults=defaults,
480
+ title=body.get("title"),
481
+ reason=body.get("reason"),
482
+ )
483
+ return ok(view, status=201)
484
+
485
+ def get_draft(self, request: Request) -> Response:
486
+ return ok(self.g.workflow.get(_principal(request), request.params["draft_id"]))
487
+
488
+ def patch_draft(self, request: Request) -> Response:
489
+ self.g.workflow.authorize(
490
+ _principal(request), request.params["draft_id"], Permission.POLICIES_WRITE
491
+ )
492
+ body = request.json()
493
+ floors = defaults = None
494
+ if "floors" in body or "defaults" in body:
495
+ floors, defaults = parse_rules(body)
496
+ return ok(
497
+ self.g.workflow.update(
498
+ _principal(request),
499
+ request.params["draft_id"],
500
+ expected_revision=body.get("expected_revision"),
501
+ floors=floors,
502
+ defaults=defaults,
503
+ title=body.get("title"),
504
+ reason=body.get("reason"),
505
+ rebase=_flag(body.get("rebase"), "rebase"),
506
+ )
507
+ )
508
+
509
+ def submit_draft(self, request: Request) -> Response:
510
+ return ok(self.g.workflow.submit(_principal(request), request.params["draft_id"]))
511
+
512
+ def approve_draft(self, request: Request) -> Response:
513
+ body = request.json()
514
+ return ok(
515
+ self.g.workflow.decide(
516
+ _principal(request),
517
+ request.params["draft_id"],
518
+ approve=True,
519
+ reason=body.get("reason"),
520
+ )
521
+ )
522
+
523
+ def reject_draft(self, request: Request) -> Response:
524
+ body = request.json()
525
+ return ok(
526
+ self.g.workflow.decide(
527
+ _principal(request),
528
+ request.params["draft_id"],
529
+ approve=False,
530
+ reason=body.get("reason"),
531
+ )
532
+ )
533
+
534
+ def cancel_draft(self, request: Request) -> Response:
535
+ return ok(self.g.workflow.cancel(_principal(request), request.params["draft_id"]))
536
+
537
+ def _rollout_hook(self, principal: Principal, raw: object): # type: ignore[no-untyped-def]
538
+ if raw is None:
539
+ return None
540
+ if not isinstance(raw, dict):
541
+ raise bad_request("rollout must be an object", "rollout")
542
+ thresholds_raw = raw.get("thresholds")
543
+ thresholds: dict[str, float] | None = None
544
+ if thresholds_raw is not None:
545
+ if not isinstance(thresholds_raw, dict):
546
+ raise bad_request("rollout.thresholds must be an object", "rollout.thresholds")
547
+ thresholds = {}
548
+ for key in ("max_error_rate", "max_block_rate", "min_scans"):
549
+ value = thresholds_raw.get(key)
550
+ if value is None:
551
+ continue
552
+ if isinstance(value, bool) or not isinstance(value, int | float) or value < 0:
553
+ raise bad_request(f"rollout.thresholds.{key} must be a number", key)
554
+ if key != "min_scans" and value > 1:
555
+ raise bad_request(f"rollout.thresholds.{key} must be at most 1", key)
556
+ thresholds[key] = float(value)
557
+ return self.g.rollouts.creator(
558
+ principal,
559
+ stages=parse_stages(raw.get("stages")),
560
+ thresholds=thresholds,
561
+ auto_pause=_flag(raw.get("auto_pause"), "rollout.auto_pause", True)
562
+ if raw.get("auto_pause") is not None
563
+ else None,
564
+ auto_rollback=_flag(raw.get("auto_rollback"), "rollout.auto_rollback")
565
+ if raw.get("auto_rollback") is not None
566
+ else None,
567
+ )
568
+
569
+ def publish_draft(self, request: Request) -> Response:
570
+ principal = _principal(request)
571
+ body = request.json()
572
+ view = self.g.workflow.publish(
573
+ principal,
574
+ request.params["draft_id"],
575
+ confirm_weakening=_flag(body.get("confirm_weakening"), "confirm_weakening"),
576
+ reason=body.get("reason"),
577
+ rollout=self._rollout_hook(principal, body.get("rollout")),
578
+ )
579
+ return ok(view)
580
+
581
+ def emergency_publish_draft(self, request: Request) -> Response:
582
+ principal = _principal(request)
583
+ body = request.json()
584
+ view = self.g.workflow.publish(
585
+ principal,
586
+ request.params["draft_id"],
587
+ confirm_weakening=_flag(body.get("confirm_weakening"), "confirm_weakening"),
588
+ emergency=True,
589
+ reason=body.get("reason"),
590
+ )
591
+ return ok(view)
592
+
593
+ # -- simulations ------------------------------------------------------ #
594
+ def simulate_draft(self, request: Request) -> Response:
595
+ principal = _principal(request)
596
+ self.g.workflow.authorize(principal, request.params["draft_id"], Permission.POLICIES_WRITE)
597
+ draft = self.g.workflow.get(principal, request.params["draft_id"])
598
+ body = request.json()
599
+ target = (
600
+ ORGANIZATION_TARGET
601
+ if draft.target.type == "organization"
602
+ else PolicyTarget(PolicyTargetType(draft.target.type), draft.target.id)
603
+ )
604
+ view = self.g.simulations.create(
605
+ principal,
606
+ draft.organization_id,
607
+ target=target,
608
+ document=canonical_document(draft.floors, draft.defaults),
609
+ draft_id=draft.id,
610
+ period_days=body.get("period_days"),
611
+ repository_ids=_optional_ids(body.get("repository_ids"), "repository_ids"),
612
+ )
613
+ return ok(view, status=202)
614
+
615
+ def list_simulations(self, request: Request) -> Response:
616
+ return ok(
617
+ self.g.simulations.list_simulations(
618
+ _principal(request),
619
+ request.params["organization_id"],
620
+ draft_id=request.arg("draft"),
621
+ )
622
+ )
623
+
624
+ def get_simulation(self, request: Request) -> Response:
625
+ return ok(self.g.simulations.get(_principal(request), request.params["simulation_id"]))
626
+
627
+ # -- rollouts --------------------------------------------------------- #
628
+ def list_rollouts(self, request: Request) -> Response:
629
+ return ok(
630
+ self.g.rollouts.list_rollouts(
631
+ _principal(request),
632
+ request.params["organization_id"],
633
+ active_only=request.arg("active") == "true",
634
+ )
635
+ )
636
+
637
+ def get_rollout(self, request: Request) -> Response:
638
+ return ok(self.g.rollouts.get(_principal(request), request.params["rollout_id"]))
639
+
640
+ def advance_rollout(self, request: Request) -> Response:
641
+ return ok(self.g.rollouts.advance(_principal(request), request.params["rollout_id"]))
642
+
643
+ def pause_rollout(self, request: Request) -> Response:
644
+ body = request.json()
645
+ return ok(
646
+ self.g.rollouts.pause(
647
+ _principal(request), request.params["rollout_id"], body.get("reason")
648
+ )
649
+ )
650
+
651
+ def resume_rollout(self, request: Request) -> Response:
652
+ return ok(self.g.rollouts.resume(_principal(request), request.params["rollout_id"]))
653
+
654
+ def rollback_rollout(self, request: Request) -> Response:
655
+ body = request.json()
656
+ return ok(
657
+ self.g.rollouts.rollback(
658
+ _principal(request),
659
+ request.params["rollout_id"],
660
+ reason=body.get("reason"),
661
+ confirm=body.get("confirm"),
662
+ )
663
+ )
664
+
665
+ def propagation(self, request: Request) -> Response:
666
+ return ok(
667
+ self.g.resolver.propagation_status(
668
+ _principal(request), request.params["organization_id"]
669
+ )
670
+ )
671
+
672
+ # -- exceptions ------------------------------------------------------- #
673
+ def list_exceptions(self, request: Request) -> Response:
674
+ principal = _principal(request)
675
+ repository = request.arg("repository")
676
+ if repository is not None and (not repository.isdigit() or len(repository) > 16):
677
+ raise bad_request("repository must be a repository ID", "repository")
678
+ offset = offset_cursor(request.arg("cursor"))
679
+ limit = parse_limit(request.arg("limit"))
680
+ items, more = self.g.exceptions.list(
681
+ principal,
682
+ request.params["organization_id"],
683
+ status=request.arg("status"),
684
+ rule_id=request.arg("rule"),
685
+ repository_id=int(repository) if repository else None,
686
+ group_id=request.arg("group"),
687
+ limit=limit,
688
+ offset=offset,
689
+ )
690
+ return ok(
691
+ items,
692
+ {"next_cursor": encode_cursor([offset + limit]) if more else None, "limit": limit},
693
+ )
694
+
695
+ def create_exception(self, request: Request) -> Response:
696
+ body = request.json()
697
+ view = self.g.exceptions.request(
698
+ _principal(request),
699
+ request.params["organization_id"],
700
+ rule_id=body.get("rule_id"),
701
+ scope_type=body.get("scope_type"),
702
+ scope_id=body.get("scope_id"),
703
+ action=body.get("action"),
704
+ reason=body.get("reason"),
705
+ expires_at=body.get("expires_at"),
706
+ permanent=body.get("permanent", False),
707
+ )
708
+ return ok(view, status=201)
709
+
710
+ def get_exception(self, request: Request) -> Response:
711
+ return ok(self.g.exceptions.get(_principal(request), request.params["exception_id"]))
712
+
713
+ def approve_exception(self, request: Request) -> Response:
714
+ body = request.json()
715
+ return ok(
716
+ self.g.exceptions.approve(
717
+ _principal(request), request.params["exception_id"], body.get("note")
718
+ )
719
+ )
720
+
721
+ def reject_exception(self, request: Request) -> Response:
722
+ body = request.json()
723
+ return ok(
724
+ self.g.exceptions.reject(
725
+ _principal(request), request.params["exception_id"], body.get("note")
726
+ )
727
+ )
728
+
729
+ def revoke_exception(self, request: Request) -> Response:
730
+ body = request.json()
731
+ return ok(
732
+ self.g.exceptions.revoke(
733
+ _principal(request), request.params["exception_id"], body.get("reason")
734
+ )
735
+ )
736
+
737
+ def cancel_exception(self, request: Request) -> Response:
738
+ return ok(self.g.exceptions.cancel(_principal(request), request.params["exception_id"]))
739
+
740
+ # -- organization rules ----------------------------------------------- #
741
+ def get_rules(self, request: Request) -> Response:
742
+ return ok(self.g.rules.view(_principal(request), request.params["organization_id"]))
743
+
744
+ def put_rules(self, request: Request) -> Response:
745
+ body = request.json()
746
+ return ok(
747
+ self.g.rules.update(
748
+ _principal(request),
749
+ request.params["organization_id"],
750
+ expected_version=body.get("expected_version"),
751
+ rules=body.get("rules"),
752
+ reason=body.get("reason"),
753
+ )
754
+ )
755
+
756
+ def rules_history(self, request: Request) -> Response:
757
+ return ok(self.g.rules.history(_principal(request), request.params["organization_id"]))
758
+
759
+ # -- bulk operations -------------------------------------------------- #
760
+ def list_bulk(self, request: Request) -> Response:
761
+ return ok(
762
+ self.g.bulk.list_operations(_principal(request), request.params["organization_id"])
763
+ )
764
+
765
+ def create_bulk(self, request: Request) -> Response:
766
+ body = request.json()
767
+ require(
768
+ _principal(request),
769
+ Permission.SCANS_TRIGGER
770
+ if body.get("type") == "schedule_scan"
771
+ else Permission.REPOSITORIES_MANAGE,
772
+ request.params["organization_id"],
773
+ )
774
+ view = self.g.bulk.create(
775
+ _principal(request),
776
+ request.params["organization_id"],
777
+ operation_type=body.get("type"),
778
+ repository_ids=repository_ids(body.get("repository_ids")),
779
+ parameters=body.get("parameters"),
780
+ idempotency_key=body.get("idempotency_key"),
781
+ confirm=body.get("confirm", False),
782
+ )
783
+ return ok(view, status=202)
784
+
785
+ def get_bulk(self, request: Request) -> Response:
786
+ return ok(self.g.bulk.get(_principal(request), request.params["operation_id"]))
787
+
788
+ def cancel_bulk(self, request: Request) -> Response:
789
+ return ok(self.g.bulk.cancel(_principal(request), request.params["operation_id"]))
790
+
791
+ def retry_bulk(self, request: Request) -> Response:
792
+ return ok(self.g.bulk.retry_failed(_principal(request), request.params["operation_id"]))
793
+
794
+ # -- scan schedules --------------------------------------------------- #
795
+ def list_schedules(self, request: Request) -> Response:
796
+ return ok(
797
+ self.g.schedules.list_schedules(_principal(request), request.params["organization_id"])
798
+ )
799
+
800
+ def create_schedule(self, request: Request) -> Response:
801
+ view = self.g.schedules.create(
802
+ _principal(request), request.params["organization_id"], request.json()
803
+ )
804
+ return ok(view, status=201)
805
+
806
+ def get_schedule(self, request: Request) -> Response:
807
+ view, runs = self.g.schedules.get(_principal(request), request.params["schedule_id"])
808
+ return ok({"schedule": view, "runs": runs})
809
+
810
+ def patch_schedule(self, request: Request) -> Response:
811
+ return ok(
812
+ self.g.schedules.update(
813
+ _principal(request), request.params["schedule_id"], request.json()
814
+ )
815
+ )
816
+
817
+ def disable_schedule(self, request: Request) -> Response:
818
+ return ok(self.g.schedules.disable(_principal(request), request.params["schedule_id"]))
819
+
820
+ # -- table ------------------------------------------------------------ #
821
+ def routes(self) -> list[RouteRow]:
822
+ org = "/organizations/{organization_id:int}"
823
+ return [
824
+ ("GET", org, self.organization, None, False, "read"),
825
+ ("GET", f"{org}/settings", self.get_settings, None, False, "read"),
826
+ ("PUT", f"{org}/settings", self.put_settings, None, False, "sensitive"),
827
+ ("GET", f"{org}/security/overview", self.security_overview, None, False, "read"),
828
+ (
829
+ "GET",
830
+ f"{org}/security/repositories",
831
+ self.security_repositories,
832
+ None,
833
+ False,
834
+ "read",
835
+ ),
836
+ ("GET", f"{org}/security/policies", self.security_policies, None, False, "read"),
837
+ ("GET", f"{org}/security/exceptions", self.security_exceptions, None, False, "read"),
838
+ ("GET", f"{org}/security/trends", self.security_trends, None, False, "read"),
839
+ ("GET", f"{org}/security/events", self.security_events, None, False, "read"),
840
+ (
841
+ "POST",
842
+ f"{org}/security/events/{{event_id:hex}}/acknowledge",
843
+ self.acknowledge_event,
844
+ None,
845
+ False,
846
+ "write",
847
+ ),
848
+ ("GET", f"{org}/reports/{{kind:ident}}", self.report, None, False, "search"),
849
+ ("GET", f"{org}/search", self.search, None, False, "search"),
850
+ ("POST", f"{org}/repositories/onboard", self.onboard, None, False, "write"),
851
+ ("POST", f"{org}/repositories/mode", self.set_mode, None, False, "sensitive"),
852
+ (
853
+ "GET",
854
+ "/repositories/{repository_id:int}/effective-policy",
855
+ self.effective_policy,
856
+ None,
857
+ False,
858
+ "read",
859
+ ),
860
+ ("GET", f"{org}/repository-groups", self.list_groups, None, False, "read"),
861
+ ("POST", f"{org}/repository-groups", self.create_group, None, False, "write"),
862
+ ("GET", "/repository-groups/{group_id:hex}", self.get_group, None, False, "read"),
863
+ ("PATCH", "/repository-groups/{group_id:hex}", self.patch_group, None, False, "write"),
864
+ (
865
+ "DELETE",
866
+ "/repository-groups/{group_id:hex}",
867
+ self.archive_group,
868
+ None,
869
+ False,
870
+ "sensitive",
871
+ ),
872
+ (
873
+ "POST",
874
+ "/repository-groups/{group_id:hex}/repositories",
875
+ self.add_group_members,
876
+ None,
877
+ False,
878
+ "write",
879
+ ),
880
+ (
881
+ "POST",
882
+ "/repository-groups/{group_id:hex}/repositories/remove",
883
+ self.remove_group_members,
884
+ None,
885
+ False,
886
+ "write",
887
+ ),
888
+ ("GET", f"{org}/policies", self.list_policy_targets, None, False, "read"),
889
+ (
890
+ "GET",
891
+ f"{org}/policy-targets/{{target_type:ident}}/versions",
892
+ self.policy_target_versions,
893
+ None,
894
+ False,
895
+ "read",
896
+ ),
897
+ (
898
+ "POST",
899
+ f"{org}/policy-targets/{{target_type:ident}}/rollback",
900
+ self.policy_target_rollback,
901
+ None,
902
+ False,
903
+ "sensitive",
904
+ ),
905
+ ("GET", f"{org}/policy-drafts", self.list_drafts, None, False, "read"),
906
+ ("POST", f"{org}/policy-drafts", self.create_draft, None, False, "write"),
907
+ ("GET", "/policy-drafts/{draft_id:hex}", self.get_draft, None, False, "read"),
908
+ ("PATCH", "/policy-drafts/{draft_id:hex}", self.patch_draft, None, False, "write"),
909
+ (
910
+ "POST",
911
+ "/policy-drafts/{draft_id:hex}/submit",
912
+ self.submit_draft,
913
+ None,
914
+ False,
915
+ "write",
916
+ ),
917
+ (
918
+ "POST",
919
+ "/policy-drafts/{draft_id:hex}/approve",
920
+ self.approve_draft,
921
+ None,
922
+ False,
923
+ "sensitive",
924
+ ),
925
+ (
926
+ "POST",
927
+ "/policy-drafts/{draft_id:hex}/reject",
928
+ self.reject_draft,
929
+ None,
930
+ False,
931
+ "write",
932
+ ),
933
+ (
934
+ "POST",
935
+ "/policy-drafts/{draft_id:hex}/cancel",
936
+ self.cancel_draft,
937
+ None,
938
+ False,
939
+ "write",
940
+ ),
941
+ (
942
+ "POST",
943
+ "/policy-drafts/{draft_id:hex}/publish",
944
+ self.publish_draft,
945
+ None,
946
+ False,
947
+ "sensitive",
948
+ ),
949
+ (
950
+ "POST",
951
+ "/policy-drafts/{draft_id:hex}/emergency-publish",
952
+ self.emergency_publish_draft,
953
+ None,
954
+ False,
955
+ "sensitive",
956
+ ),
957
+ (
958
+ "POST",
959
+ "/policy-drafts/{draft_id:hex}/simulations",
960
+ self.simulate_draft,
961
+ None,
962
+ False,
963
+ "write",
964
+ ),
965
+ ("GET", f"{org}/simulations", self.list_simulations, None, False, "read"),
966
+ (
967
+ "GET",
968
+ "/simulations/{simulation_id:hex}",
969
+ self.get_simulation,
970
+ None,
971
+ False,
972
+ "read",
973
+ ),
974
+ ("GET", f"{org}/rollouts", self.list_rollouts, None, False, "read"),
975
+ ("GET", "/rollouts/{rollout_id:hex}", self.get_rollout, None, False, "read"),
976
+ (
977
+ "POST",
978
+ "/rollouts/{rollout_id:hex}/advance",
979
+ self.advance_rollout,
980
+ None,
981
+ False,
982
+ "sensitive",
983
+ ),
984
+ (
985
+ "POST",
986
+ "/rollouts/{rollout_id:hex}/pause",
987
+ self.pause_rollout,
988
+ None,
989
+ False,
990
+ "write",
991
+ ),
992
+ (
993
+ "POST",
994
+ "/rollouts/{rollout_id:hex}/resume",
995
+ self.resume_rollout,
996
+ None,
997
+ False,
998
+ "sensitive",
999
+ ),
1000
+ (
1001
+ "POST",
1002
+ "/rollouts/{rollout_id:hex}/rollback",
1003
+ self.rollback_rollout,
1004
+ None,
1005
+ False,
1006
+ "sensitive",
1007
+ ),
1008
+ ("GET", f"{org}/policy-propagation", self.propagation, None, False, "read"),
1009
+ ("GET", f"{org}/exceptions", self.list_exceptions, None, False, "read"),
1010
+ ("POST", f"{org}/exceptions", self.create_exception, None, False, "sensitive"),
1011
+ ("GET", "/exceptions/{exception_id:hex}", self.get_exception, None, False, "read"),
1012
+ (
1013
+ "POST",
1014
+ "/exceptions/{exception_id:hex}/approve",
1015
+ self.approve_exception,
1016
+ None,
1017
+ False,
1018
+ "sensitive",
1019
+ ),
1020
+ (
1021
+ "POST",
1022
+ "/exceptions/{exception_id:hex}/reject",
1023
+ self.reject_exception,
1024
+ None,
1025
+ False,
1026
+ "write",
1027
+ ),
1028
+ (
1029
+ "POST",
1030
+ "/exceptions/{exception_id:hex}/revoke",
1031
+ self.revoke_exception,
1032
+ None,
1033
+ False,
1034
+ "sensitive",
1035
+ ),
1036
+ (
1037
+ "POST",
1038
+ "/exceptions/{exception_id:hex}/cancel",
1039
+ self.cancel_exception,
1040
+ None,
1041
+ False,
1042
+ "write",
1043
+ ),
1044
+ ("GET", f"{org}/rules", self.get_rules, None, False, "read"),
1045
+ ("PUT", f"{org}/rules", self.put_rules, None, False, "sensitive"),
1046
+ ("GET", f"{org}/rules/history", self.rules_history, None, False, "read"),
1047
+ ("GET", f"{org}/bulk-operations", self.list_bulk, None, False, "read"),
1048
+ ("POST", f"{org}/bulk-operations", self.create_bulk, None, False, "sensitive"),
1049
+ ("GET", "/bulk-operations/{operation_id:hex}", self.get_bulk, None, False, "read"),
1050
+ (
1051
+ "POST",
1052
+ "/bulk-operations/{operation_id:hex}/cancel",
1053
+ self.cancel_bulk,
1054
+ None,
1055
+ False,
1056
+ "write",
1057
+ ),
1058
+ (
1059
+ "POST",
1060
+ "/bulk-operations/{operation_id:hex}/retry",
1061
+ self.retry_bulk,
1062
+ None,
1063
+ False,
1064
+ "write",
1065
+ ),
1066
+ ("GET", f"{org}/scan-schedules", self.list_schedules, None, False, "read"),
1067
+ ("POST", f"{org}/scan-schedules", self.create_schedule, None, False, "write"),
1068
+ ("GET", "/scan-schedules/{schedule_id:hex}", self.get_schedule, None, False, "read"),
1069
+ (
1070
+ "PATCH",
1071
+ "/scan-schedules/{schedule_id:hex}",
1072
+ self.patch_schedule,
1073
+ None,
1074
+ False,
1075
+ "write",
1076
+ ),
1077
+ (
1078
+ "POST",
1079
+ "/scan-schedules/{schedule_id:hex}/disable",
1080
+ self.disable_schedule,
1081
+ None,
1082
+ False,
1083
+ "write",
1084
+ ),
1085
+ ]