preflight-cli 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.
preflight/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
preflight/cli.py ADDED
@@ -0,0 +1,444 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections.abc import Sequence
5
+ from pathlib import Path
6
+ from typing import Annotated, Any, NoReturn
7
+
8
+ import click
9
+ import typer
10
+
11
+ from preflight import __version__, keys, ui
12
+ from preflight.core.cfn import DEFAULT_FILENAME, render_role_template
13
+ from preflight.core.iam import collect_actions, selectable_services
14
+ from preflight.modules import (
15
+ BASE_ACTIONS,
16
+ REGISTRY,
17
+ Module,
18
+ ModuleError,
19
+ ModuleNotAvailableError,
20
+ UnknownModuleError,
21
+ available_modules,
22
+ resolve,
23
+ )
24
+
25
+ app = typer.Typer(
26
+ name="preflight",
27
+ no_args_is_help=True,
28
+ add_completion=False,
29
+ help="Free, read-only AWS audit CLI. Finds the first three things to fix "
30
+ "in deploys, cost, and bus factor.",
31
+ )
32
+
33
+ TOTAL_STEPS = 3
34
+
35
+
36
+ def _show_version(value: bool) -> None:
37
+ """`--version` on stdout, so a script can read it."""
38
+ if value:
39
+ typer.echo(f"preflight {__version__}")
40
+ raise typer.Exit()
41
+
42
+
43
+ @app.callback()
44
+ def main(
45
+ version: Annotated[
46
+ bool,
47
+ typer.Option(
48
+ "--version",
49
+ "-V",
50
+ callback=_show_version,
51
+ is_eager=True,
52
+ help="Show the version and exit.",
53
+ ),
54
+ ] = False,
55
+ ) -> None:
56
+ pass
57
+
58
+
59
+ def _tokens(raw: str) -> list[str]:
60
+ """Split `cost, security` or `1 2` into tokens."""
61
+ return [token for token in re.split(r"[,\s]+", raw.strip()) if token]
62
+
63
+
64
+ def _select(raw: str) -> tuple[Module, ...]:
65
+ """Turn picker input — numbers, keys, or `all` — into modules."""
66
+ options = available_modules()
67
+ wanted: list[str] = []
68
+ for token in _tokens(raw):
69
+ if token.isdigit():
70
+ index = int(token)
71
+ if not 1 <= index <= len(options):
72
+ raise ModuleError(f"There's no module {index} in the list.")
73
+ wanted.append(options[index - 1].key)
74
+ else:
75
+ wanted.append(token)
76
+ if not wanted:
77
+ raise ModuleError("Nothing selected.")
78
+ return resolve(wanted)
79
+
80
+
81
+ def _report_selection_error(exc: ModuleError) -> None:
82
+ if isinstance(exc, UnknownModuleError):
83
+ ui.print_error(f"Don't know {_quote(exc.keys)}.")
84
+ elif isinstance(exc, ModuleNotAvailableError):
85
+ ui.print_error(f"{_quote(exc.keys)} isn't available yet.")
86
+ else:
87
+ ui.print_error(str(exc))
88
+ ui.print_hint(f"Available now: {_quote(tuple(m.key for m in available_modules()))}")
89
+
90
+
91
+ def _quote(keys: tuple[str, ...]) -> str:
92
+ return ", ".join(f"'{key}'" for key in keys) if keys else "nothing"
93
+
94
+
95
+ def _abandoned() -> NoReturn:
96
+ """Nobody answered — say so, and point at the non-interactive route."""
97
+ ui.console.print()
98
+ ui.print_error("No answer given, so nothing was written.")
99
+ ui.print_hint("In a script, skip the questions: preflight scan --modules cost --save")
100
+ raise typer.Exit(2)
101
+
102
+
103
+ def _ask(text: str, **kwargs: Any) -> str:
104
+ """Ask a question, and fail helpfully when there's no one to answer."""
105
+ try:
106
+ return ui.ask(text, **kwargs)
107
+ except (EOFError, KeyboardInterrupt, click.Abort):
108
+ _abandoned()
109
+
110
+
111
+ def _confirm(text: str) -> bool:
112
+ try:
113
+ return ui.confirm(text)
114
+ except (EOFError, KeyboardInterrupt, click.Abort):
115
+ return False
116
+
117
+
118
+ def _fail(message: str, hint: str | None = None, code: int = 1) -> NoReturn:
119
+ ui.print_error(message)
120
+ if hint:
121
+ ui.print_hint(hint)
122
+ raise typer.Exit(code)
123
+
124
+
125
+ def _prompt_for_modules() -> tuple[Module, ...]:
126
+ """Ask until the answer resolves to something we can run."""
127
+ default = "1" if available_modules() else None
128
+ while True:
129
+ raw = _ask("Modules to check (number, name, or 'all')", default=default)
130
+ try:
131
+ return _select(raw)
132
+ except ModuleError as exc:
133
+ _report_selection_error(exc)
134
+ ui.console.print()
135
+
136
+
137
+ #: Words the permission picker understands on top of numbers and service names.
138
+ SELECT_ALL = {"a", "all", "*"}
139
+ SELECT_NONE = {"n", "none", "clear"}
140
+ PICKER_DONE = {"", "d", "done", "ok", "go", "continue", "y", "yes"}
141
+
142
+
143
+ def _select_services(raw: str, groups: dict[str, tuple[str, ...]]) -> list[str]:
144
+ """Turn `1 3 ec2` into service names, in picker order."""
145
+ names = list(groups)
146
+ chosen: list[str] = []
147
+ for token in _tokens(raw):
148
+ if token.isdigit():
149
+ index = int(token)
150
+ if not 1 <= index <= len(names):
151
+ raise ModuleError(f"There's no permission {index} in the list.")
152
+ chosen.append(names[index - 1])
153
+ elif token.lower() in groups:
154
+ chosen.append(token.lower())
155
+ else:
156
+ raise ModuleError(f"Don't know the service '{token}'.")
157
+ # Deduplicated: a service named twice in one answer still toggles once.
158
+ return list(dict.fromkeys(chosen))
159
+
160
+
161
+ def _requested_services(raw: str | None, groups: dict[str, tuple[str, ...]]) -> tuple[str, ...]:
162
+ """Resolve `--services ec2,rds` against what the selected modules declare."""
163
+ if raw is None:
164
+ return tuple(groups)
165
+ wanted = [token.lower() for token in _tokens(raw)]
166
+ if "all" in wanted:
167
+ return tuple(groups)
168
+ unknown = tuple(dict.fromkeys(token for token in wanted if token not in groups))
169
+ if unknown:
170
+ ui.print_error(f"The modules you picked don't use {_quote(unknown)}.")
171
+ ui.print_hint(f"Services they do use: {_quote(tuple(groups))}")
172
+ raise typer.Exit(2)
173
+ if not wanted:
174
+ ui.print_error("No services selected, so there'd be no permissions to grant.")
175
+ ui.print_hint(f"Pick from: {_quote(tuple(groups))}")
176
+ raise typer.Exit(2)
177
+ chosen = set(wanted)
178
+ return tuple(service for service in groups if service in chosen)
179
+
180
+
181
+ ARROW_KEYS = "Up/Down to move, Enter to check or uncheck, 'a' for all, 'n' for none"
182
+ TYPED_KEYS = "Toggle by number or name, 'all', 'none', or Enter to continue"
183
+
184
+ NOTHING_PICKED = "Nothing selected, so there'd be no permissions to grant."
185
+
186
+
187
+ def _prompt_for_services(
188
+ selected: tuple[Module, ...],
189
+ groups: dict[str, tuple[str, ...]],
190
+ start: Sequence[str],
191
+ ) -> tuple[str, ...]:
192
+ """Let the user switch off services they'd rather not grant.
193
+
194
+ Everything starts checked unless `--services` narrowed it, so continuing
195
+ straight away grants exactly what the modules asked for. Arrow keys when
196
+ there's a terminal to read them from, typed answers otherwise, so the
197
+ flow still works down a pipe.
198
+ """
199
+ picker = _arrow_picker if keys.supported() else _typed_picker
200
+ return picker(selected, groups, set(start))
201
+
202
+
203
+ def _arrow_picker(
204
+ selected: tuple[Module, ...],
205
+ groups: dict[str, tuple[str, ...]],
206
+ picked: set[str],
207
+ ) -> tuple[str, ...]:
208
+ """Up/Down to move, Enter to toggle, Enter on the last row to continue."""
209
+ services = list(groups)
210
+ cursor = 0
211
+ error: str | None = None
212
+ while True:
213
+ _redraw_permissions(selected, groups, picked, error, cursor=cursor, hint=ARROW_KEYS)
214
+ error = None
215
+ try:
216
+ key = keys.read_key()
217
+ except (EOFError, KeyboardInterrupt):
218
+ _abandoned()
219
+ if key == keys.UP:
220
+ cursor = (cursor - 1) % (len(services) + 1)
221
+ elif key == keys.DOWN:
222
+ cursor = (cursor + 1) % (len(services) + 1)
223
+ elif key == "a":
224
+ picked = set(groups)
225
+ elif key == "n":
226
+ picked = set()
227
+ elif key in (keys.ENTER, keys.SPACE):
228
+ # The row under the services is the one that leaves the picker.
229
+ if cursor < len(services):
230
+ picked.symmetric_difference_update({services[cursor]})
231
+ elif picked:
232
+ return tuple(service for service in groups if service in picked)
233
+ else:
234
+ error = NOTHING_PICKED
235
+
236
+
237
+ def _typed_picker(
238
+ selected: tuple[Module, ...],
239
+ groups: dict[str, tuple[str, ...]],
240
+ picked: set[str],
241
+ ) -> tuple[str, ...]:
242
+ """The same picker for terminals we can't read a single keypress from."""
243
+ error: str | None = None
244
+ while True:
245
+ _redraw_permissions(selected, groups, picked, error, hint=TYPED_KEYS)
246
+ error = None
247
+ raw = _ask("Toggle, 'all', 'none', or Enter to continue", default="", show_default=False)
248
+ token = raw.strip().lower()
249
+ if token in SELECT_ALL:
250
+ picked = set(groups)
251
+ continue
252
+ if token in SELECT_NONE:
253
+ picked = set()
254
+ continue
255
+ if token in PICKER_DONE:
256
+ if picked:
257
+ return tuple(service for service in groups if service in picked)
258
+ error = NOTHING_PICKED
259
+ continue
260
+ try:
261
+ picked.symmetric_difference_update(_select_services(raw, groups))
262
+ except ModuleError as exc:
263
+ error = str(exc)
264
+
265
+
266
+ def _redraw_permissions(
267
+ selected: tuple[Module, ...],
268
+ groups: dict[str, tuple[str, ...]],
269
+ picked: set[str],
270
+ error: str | None,
271
+ *,
272
+ cursor: int | None = None,
273
+ hint: str | None = None,
274
+ ) -> None:
275
+ ui.clear_screen()
276
+ ui.print_step(2, TOTAL_STEPS, "Choose the permissions")
277
+ ui.print_checking(selected)
278
+ ui.print_permissions(groups, picked, always=BASE_ACTIONS, cursor=cursor, keys=hint)
279
+ if error:
280
+ ui.print_error(error)
281
+ ui.console.print()
282
+
283
+
284
+ def _resolve_target(out: Path | None, save: bool) -> Path | None:
285
+ """Where the template should be written, if anywhere."""
286
+ if out is None:
287
+ return Path(DEFAULT_FILENAME) if save else None
288
+ # A directory means "in here, under the usual name".
289
+ return out / DEFAULT_FILENAME if out.is_dir() else out
290
+
291
+
292
+ def _write_template(path: Path, text: str, *, force: bool, interactive: bool) -> None:
293
+ if path.exists():
294
+ if not path.is_file():
295
+ _fail(f"{path} isn't a file.")
296
+ if not force and not (interactive and _confirm(f"{path} already exists. Overwrite it?")):
297
+ _fail(
298
+ f"{path} already exists, so nothing was written.",
299
+ "Re-run with --force to overwrite it, or --out <path> to write elsewhere.",
300
+ )
301
+ try:
302
+ path.write_text(text, encoding="utf-8")
303
+ except OSError as exc:
304
+ _fail(f"Couldn't write {path}: {exc.strerror or exc}")
305
+
306
+
307
+ @app.command()
308
+ def demo() -> None:
309
+ """Render a sample report with no AWS access required."""
310
+ typer.echo("demo: not yet implemented")
311
+
312
+
313
+ @app.command()
314
+ def scan(
315
+ modules: Annotated[
316
+ str | None,
317
+ typer.Option(
318
+ "--modules",
319
+ "-m",
320
+ metavar="KEYS",
321
+ help="Modules to include, comma-separated (e.g. 'cost' or 'all'). Skips the questions.",
322
+ ),
323
+ ] = None,
324
+ services: Annotated[
325
+ str | None,
326
+ typer.Option(
327
+ "--services",
328
+ "-s",
329
+ metavar="SERVICES",
330
+ help="Narrow the role to these AWS services, comma-separated (e.g. 'ec2,rds'). "
331
+ "Defaults to everything the selected modules need.",
332
+ show_default=False,
333
+ ),
334
+ ] = None,
335
+ save: Annotated[
336
+ bool,
337
+ typer.Option("--save", help=f"Write the role template to ./{DEFAULT_FILENAME}."),
338
+ ] = False,
339
+ out: Annotated[
340
+ Path | None,
341
+ typer.Option(
342
+ "--out",
343
+ "-o",
344
+ help="Write the role template to this path instead.",
345
+ show_default=False,
346
+ ),
347
+ ] = None,
348
+ show: Annotated[
349
+ bool, typer.Option("--print", help="Write the role template to stdout.")
350
+ ] = False,
351
+ force: Annotated[
352
+ bool, typer.Option("--force", help="Overwrite the output file if it exists.")
353
+ ] = False,
354
+ list_modules: Annotated[
355
+ bool, typer.Option("--list-modules", help="List the modules and exit.")
356
+ ] = False,
357
+ profile: Annotated[str | None, typer.Option(help="AWS profile to use.")] = None,
358
+ region: Annotated[str | None, typer.Option(help="AWS region to scan.")] = None,
359
+ role_arn: Annotated[
360
+ str | None, typer.Option(help="IAM role to assume before scanning.")
361
+ ] = None,
362
+ ) -> None:
363
+ """Pick what to check and generate the read-only IAM role it needs.
364
+
365
+ Runs entirely offline: no AWS calls, no credentials, nothing leaves your
366
+ machine. You get a CloudFormation template you can read before you deploy
367
+ it, granting only the Get/List/Describe actions the modules you picked
368
+ declare.
369
+ """
370
+ ui.print_banner()
371
+
372
+ if list_modules:
373
+ ui.print_modules(REGISTRY)
374
+ ui.console.print(
375
+ "Generate the role for one: [bold]preflight scan --modules cost --save[/bold]"
376
+ )
377
+ raise typer.Exit()
378
+
379
+ if profile or region or role_arn:
380
+ ui.console.print(
381
+ "[yellow]![/yellow] The checks themselves aren't wired up yet, so "
382
+ "--profile/--region/--role-arn do nothing for now."
383
+ )
384
+ ui.print_hint("This release gets the read-only role in place first.\n")
385
+
386
+ interactive = modules is None
387
+
388
+ ui.print_step(1, TOTAL_STEPS, "Choose what to check")
389
+ if interactive:
390
+ ui.print_modules(REGISTRY)
391
+ selected = _prompt_for_modules()
392
+ else:
393
+ try:
394
+ selected = _select(modules)
395
+ except ModuleError as exc:
396
+ _report_selection_error(exc)
397
+ raise typer.Exit(2) from None
398
+
399
+ # Step 2 gets a screen of its own: the picker is something you read and
400
+ # change, not something you scroll back through.
401
+ groups = selectable_services(selected)
402
+ requested = _requested_services(services, groups)
403
+ if interactive:
404
+ chosen_services = _prompt_for_services(selected, groups, requested)
405
+ ui.clear_screen()
406
+ else:
407
+ ui.print_step(2, TOTAL_STEPS, "Review the permissions")
408
+ chosen_services = requested
409
+ ui.print_checking(selected)
410
+ ui.print_permissions(groups, set(chosen_services), always=BASE_ACTIONS)
411
+
412
+ narrowed = tuple(chosen_services) if set(chosen_services) != set(groups) else None
413
+ actions = collect_actions(selected, narrowed)
414
+ template = render_role_template(selected, services=narrowed)
415
+
416
+ ui.print_step(3, TOTAL_STEPS, "Save the template")
417
+ ui.print_selection(selected, actions)
418
+ target = _resolve_target(out, save)
419
+ to_stdout = show
420
+
421
+ if interactive and not to_stdout and target is None:
422
+ ui.print_save_hint(DEFAULT_FILENAME)
423
+ answer = _ask("Save to", default=DEFAULT_FILENAME)
424
+ target = _resolve_target(Path(answer.strip() or DEFAULT_FILENAME), True)
425
+ elif target is None and not to_stdout:
426
+ # Non-interactive with no destination: stdout is the useful default.
427
+ to_stdout = True
428
+
429
+ if to_stdout:
430
+ ui.emit_template(template)
431
+ if target is not None:
432
+ _write_template(target, template, force=force, interactive=interactive)
433
+ ui.print_saved(str(target), actions)
434
+ ui.print_next_steps(str(target))
435
+
436
+
437
+ @app.command()
438
+ def preview() -> None:
439
+ """Show exactly what would be sent if you opt in to email delivery."""
440
+ typer.echo("preview: not yet implemented")
441
+
442
+
443
+ if __name__ == "__main__":
444
+ app()
File without changes
preflight/core/cfn.py ADDED
@@ -0,0 +1,97 @@
1
+ """Builds the CloudFormation role template from the selected modules.
2
+
3
+ The only input is the module registry — no AWS calls, no credentials, no
4
+ network. The output is deterministic: the same modules always produce the same
5
+ bytes, so a user who regenerates the template after upgrading Preflight gets a
6
+ readable diff instead of a reshuffled file.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Sequence
12
+ from importlib import resources
13
+
14
+ from jinja2 import Environment, StrictUndefined
15
+
16
+ from preflight import __version__
17
+ from preflight.core.iam import collect_actions
18
+ from preflight.modules import BASE_ACTIONS, Module
19
+
20
+ TEMPLATE_NAME = "role.cfn.yaml.j2"
21
+ DEFAULT_FILENAME = "preflight-role.cfn.yaml"
22
+ POLICY_NAME = "PreflightReadOnlyPolicy"
23
+ ROLE_NAME = "PreflightReadOnlyRole"
24
+
25
+
26
+ def regenerate_command(modules: Sequence[Module], services: Sequence[str] | None = None) -> str:
27
+ """The command that reproduces a template, recorded in its header."""
28
+ keys = ",".join(module.key for module in modules)
29
+ narrowed = f" --services {','.join(services)}" if services else ""
30
+ return f"preflight scan --modules {keys}{narrowed} --save"
31
+
32
+
33
+ def _load_template() -> str:
34
+ return (
35
+ resources.files("preflight")
36
+ .joinpath("templates", TEMPLATE_NAME)
37
+ .read_text(encoding="utf-8")
38
+ )
39
+
40
+
41
+ def render_role_template(
42
+ modules: Sequence[Module],
43
+ *,
44
+ services: Sequence[str] | None = None,
45
+ version: str = __version__,
46
+ ) -> str:
47
+ """Render the read-only CloudFormation role template for `modules`.
48
+
49
+ `services` narrows the role to a subset of the services the modules
50
+ declare — what the permission picker produces when the user turns some of
51
+ them off. Raises `ValueError` if no modules were given or if the narrowing
52
+ leaves nothing to grant, and `preflight.core.iam.WriteActionError` if any
53
+ declared action isn't provably read-only.
54
+ """
55
+ if not modules:
56
+ raise ValueError("at least one module is required to generate a role")
57
+
58
+ actions = collect_actions(modules, services)
59
+ if set(actions) <= set(BASE_ACTIONS):
60
+ raise ValueError("at least one service is required to generate a role")
61
+
62
+ env = Environment(
63
+ trim_blocks=True,
64
+ lstrip_blocks=True,
65
+ keep_trailing_newline=True,
66
+ undefined=StrictUndefined,
67
+ autoescape=False, # YAML output, not HTML
68
+ )
69
+ template = env.from_string(_load_template())
70
+ return template.render(
71
+ version=version,
72
+ modules=tuple(_module_rows(modules, actions)),
73
+ module_names=", ".join(module.name for module in modules),
74
+ actions=actions,
75
+ base_actions=BASE_ACTIONS,
76
+ selected_services=tuple(services) if services else (),
77
+ policy_name=POLICY_NAME,
78
+ role_name=ROLE_NAME,
79
+ regenerate_command=regenerate_command(modules, services),
80
+ )
81
+
82
+
83
+ def _module_rows(modules: Sequence[Module], actions: Sequence[str]) -> list[dict[str, object]]:
84
+ """One header row per module, counting only the actions that made the cut.
85
+
86
+ The count has to come from the rendered action list rather than the module
87
+ declaration: with a narrowed service selection they aren't the same number.
88
+ """
89
+ granted = set(actions)
90
+ return [
91
+ {
92
+ "name": module.name,
93
+ "key": module.key,
94
+ "count": len(granted.intersection(module.iam_actions)),
95
+ }
96
+ for module in modules
97
+ ]
preflight/core/iam.py ADDED
@@ -0,0 +1,108 @@
1
+ """The read-only invariant.
2
+
3
+ Preflight's main promise is that it cannot change anything in your account.
4
+ This module is where that promise is enforced in code: every action that
5
+ reaches a generated IAM policy passes through `assert_read_only` first, so a
6
+ mistake in a module declaration fails at generation time instead of ending up
7
+ in a role someone deploys.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Iterable
13
+
14
+ from preflight.modules.base import BASE_ACTIONS, Module
15
+
16
+ #: Action verbs that cannot mutate state.
17
+ READ_ONLY_PREFIXES: tuple[str, ...] = ("Get", "List", "Describe")
18
+
19
+ #: Read-only actions that don't follow the Get/List/Describe naming convention.
20
+ #: Keep in sync with tests/test_policy.py and the policy-check CI job. Any
21
+ #: addition here must be justified in the pull request that makes it.
22
+ READ_ONLY_EXCEPTIONS: frozenset[str] = frozenset(
23
+ {
24
+ "cloudtrail:LookupEvents", # read-only event query, used by Bus factor
25
+ }
26
+ )
27
+
28
+
29
+ class WriteActionError(ValueError):
30
+ """An action that isn't provably read-only reached policy generation."""
31
+
32
+ def __init__(self, actions: tuple[str, ...]) -> None:
33
+ self.actions = actions
34
+ super().__init__(
35
+ "refusing to generate a policy containing actions that are not "
36
+ f"read-only: {', '.join(actions)}"
37
+ )
38
+
39
+
40
+ def is_read_only_action(action: str) -> bool:
41
+ """Whether `action` is a specific, non-mutating AWS action.
42
+
43
+ Wildcards are never read-only: `*` and `s3:*` would both grant writes.
44
+ """
45
+ if action in READ_ONLY_EXCEPTIONS:
46
+ return True
47
+ if "*" in action or ":" not in action:
48
+ return False
49
+ _service, verb = action.split(":", 1)
50
+ return verb.startswith(READ_ONLY_PREFIXES)
51
+
52
+
53
+ def assert_read_only(actions: Iterable[str]) -> None:
54
+ """Raise `WriteActionError` if any action could change something."""
55
+ offenders = tuple(action for action in actions if not is_read_only_action(action))
56
+ if offenders:
57
+ raise WriteActionError(offenders)
58
+
59
+
60
+ def service_of(action: str) -> str:
61
+ """The AWS service an action belongs to: `ec2` for `ec2:DescribeVolumes`."""
62
+ return action.split(":", 1)[0]
63
+
64
+
65
+ def group_by_service(actions: Iterable[str]) -> dict[str, tuple[str, ...]]:
66
+ """Actions bucketed by service, both the buckets and their contents sorted."""
67
+ groups: dict[str, list[str]] = {}
68
+ for action in actions:
69
+ groups.setdefault(service_of(action), []).append(action)
70
+ return {service: tuple(sorted(groups[service])) for service in sorted(groups)}
71
+
72
+
73
+ def selectable_services(modules: Iterable[Module]) -> dict[str, tuple[str, ...]]:
74
+ """The service groups a user is allowed to narrow a role down to.
75
+
76
+ `BASE_ACTIONS` are left out: every scan needs them, so they aren't
77
+ something the picker can switch off.
78
+ """
79
+ actions: set[str] = set()
80
+ for module in modules:
81
+ actions.update(module.iam_actions)
82
+ return group_by_service(actions - set(BASE_ACTIONS))
83
+
84
+
85
+ def collect_actions(
86
+ modules: Iterable[Module], services: Iterable[str] | None = None
87
+ ) -> tuple[str, ...]:
88
+ """The deduplicated, sorted actions needed to run `modules`.
89
+
90
+ Always includes `BASE_ACTIONS`. Passing `services` narrows the result to
91
+ those services, which is how the picker in `preflight scan` hands back a
92
+ role smaller than the modules alone would imply. Sorting keeps a
93
+ regenerated template byte-identical to the last one, so the file diffs
94
+ cleanly when a module's permissions change.
95
+ """
96
+ actions = set(BASE_ACTIONS)
97
+ for module in modules:
98
+ actions.update(module.iam_actions)
99
+ if services is not None:
100
+ wanted = {service.strip().lower() for service in services}
101
+ actions = {
102
+ action
103
+ for action in actions
104
+ if action in BASE_ACTIONS or service_of(action).lower() in wanted
105
+ }
106
+ ordered = tuple(sorted(actions, key=lambda action: (service_of(action), action)))
107
+ assert_read_only(ordered)
108
+ return ordered