iam-policy-validator 1.14.7__py3-none-any.whl → 1.15.1__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 (42) hide show
  1. {iam_policy_validator-1.14.7.dist-info → iam_policy_validator-1.15.1.dist-info}/METADATA +16 -11
  2. {iam_policy_validator-1.14.7.dist-info → iam_policy_validator-1.15.1.dist-info}/RECORD +41 -28
  3. iam_policy_validator-1.15.1.dist-info/entry_points.txt +4 -0
  4. iam_validator/__version__.py +1 -1
  5. iam_validator/checks/__init__.py +2 -0
  6. iam_validator/checks/action_validation.py +91 -27
  7. iam_validator/checks/not_action_not_resource.py +163 -0
  8. iam_validator/checks/resource_validation.py +132 -81
  9. iam_validator/checks/wildcard_resource.py +136 -6
  10. iam_validator/commands/__init__.py +3 -0
  11. iam_validator/commands/cache.py +66 -24
  12. iam_validator/commands/completion.py +94 -15
  13. iam_validator/commands/mcp.py +210 -0
  14. iam_validator/commands/query.py +489 -65
  15. iam_validator/core/aws_service/__init__.py +5 -1
  16. iam_validator/core/aws_service/cache.py +20 -0
  17. iam_validator/core/aws_service/fetcher.py +180 -11
  18. iam_validator/core/aws_service/storage.py +14 -6
  19. iam_validator/core/aws_service/validators.py +68 -51
  20. iam_validator/core/check_registry.py +100 -35
  21. iam_validator/core/config/aws_global_conditions.py +18 -9
  22. iam_validator/core/config/check_documentation.py +104 -51
  23. iam_validator/core/config/config_loader.py +39 -3
  24. iam_validator/core/config/defaults.py +6 -0
  25. iam_validator/core/constants.py +11 -4
  26. iam_validator/core/models.py +39 -14
  27. iam_validator/mcp/__init__.py +162 -0
  28. iam_validator/mcp/models.py +118 -0
  29. iam_validator/mcp/server.py +2928 -0
  30. iam_validator/mcp/session_config.py +319 -0
  31. iam_validator/mcp/templates/__init__.py +79 -0
  32. iam_validator/mcp/templates/builtin.py +856 -0
  33. iam_validator/mcp/tools/__init__.py +72 -0
  34. iam_validator/mcp/tools/generation.py +888 -0
  35. iam_validator/mcp/tools/org_config_tools.py +263 -0
  36. iam_validator/mcp/tools/query.py +395 -0
  37. iam_validator/mcp/tools/validation.py +376 -0
  38. iam_validator/sdk/__init__.py +2 -0
  39. iam_validator/sdk/policy_utils.py +31 -5
  40. iam_policy_validator-1.14.7.dist-info/entry_points.txt +0 -2
  41. {iam_policy_validator-1.14.7.dist-info → iam_policy_validator-1.15.1.dist-info}/WHEEL +0 -0
  42. {iam_policy_validator-1.14.7.dist-info → iam_policy_validator-1.15.1.dist-info}/licenses/LICENSE +0 -0
@@ -13,8 +13,30 @@ Examples:
13
13
  # Query actions that support wildcard resource
14
14
  iam-validator query action --service s3 --resource-type "*"
15
15
 
16
- # Query action details
16
+ # Query action details (two equivalent forms)
17
17
  iam-validator query action --service s3 --name GetObject
18
+ iam-validator query action --name s3:GetObject
19
+
20
+ # Query multiple actions at once
21
+ iam-validator query action --name dynamodb:Query dynamodb:Scan s3:GetObject
22
+ iam-validator query action --service dynamodb --name Query Scan GetItem
23
+
24
+ # Query with service prefix in --name (--service not required)
25
+ iam-validator query action --name iam:CreateRole
26
+ iam-validator query arn --name s3:bucket
27
+ iam-validator query condition --name s3:prefix
28
+
29
+ # Expand wildcard patterns to matching actions
30
+ iam-validator query action --name "iam:Get*" --output text
31
+ iam-validator query action --name "s3:*Object*" --output json
32
+
33
+ # Mix exact actions and wildcards
34
+ iam-validator query action --name dynamodb:Query dynamodb:Create* --output yaml
35
+
36
+ # Filter output to specific fields
37
+ iam-validator query action --name dynamodb:Query dynamodb:Scan --show-condition-keys
38
+ iam-validator query action --name "s3:Get*" --show-resource-types --output text
39
+ iam-validator query action --name iam:CreateRole --show-access-level --show-condition-keys
18
40
 
19
41
  # Query ARN formats for a service
20
42
  iam-validator query arn --service s3
@@ -28,15 +50,17 @@ Examples:
28
50
  # Query specific condition key
29
51
  iam-validator query condition --service s3 --name s3:prefix
30
52
 
31
- # Text format for simple output (great for piping)
32
- iam-validator query action --service s3 --output text | grep Delete
33
- iam-validator query action --service iam --access-level write --output text
53
+ # Text format for simple output (great for piping)
54
+ iam-validator query action --service s3 --output text | grep Delete
55
+ iam-validator query action --service iam --access-level write --output text
34
56
  """
35
57
 
36
58
  import argparse
59
+ import asyncio
37
60
  import json
38
61
  import logging
39
62
  import sys
63
+ from collections import defaultdict
40
64
  from typing import Any
41
65
 
42
66
  import yaml
@@ -74,8 +98,32 @@ examples:
74
98
  # Query actions that support wildcard resource
75
99
  iam-validator query action --service s3 --resource-type "*"
76
100
 
77
- # Query action details
101
+ # Query action details (two equivalent forms)
78
102
  iam-validator query action --service s3 --name GetObject
103
+ iam-validator query action --name s3:GetObject
104
+
105
+ # Query multiple actions at once
106
+ iam-validator query action --name dynamodb:Query dynamodb:Scan s3:GetObject
107
+ iam-validator query action --service dynamodb --name Query Scan GetItem
108
+
109
+ # Query with service prefix in --name (--service not required)
110
+ iam-validator query action --name iam:CreateRole
111
+ iam-validator query arn --name s3:bucket
112
+ iam-validator query condition --name s3:prefix
113
+
114
+ # Expand wildcard patterns to matching actions
115
+ iam-validator query action --name "iam:Get*" --output text
116
+ iam-validator query action --name "s3:*Object*" --output json
117
+ iam-validator query action --service ec2 --name "Describe*" --output text
118
+
119
+ # Mix exact actions and wildcards in one query
120
+ iam-validator query action --name dynamodb:Query dynamodb:Create* --output yaml
121
+ iam-validator query action --name s3:GetObject "s3:Put*" iam:GetRole --output json
122
+
123
+ # Filter output to specific fields
124
+ iam-validator query action --name dynamodb:Query dynamodb:Scan --show-condition-keys
125
+ iam-validator query action --name "s3:Get*" --show-resource-types --output text
126
+ iam-validator query action --name iam:CreateRole --show-access-level --show-condition-keys
79
127
 
80
128
  # Query ARN formats for a service
81
129
  iam-validator query arn --service s3
@@ -115,12 +163,13 @@ note:
115
163
  )
116
164
  action_parser.add_argument(
117
165
  "--service",
118
- required=True,
119
- help="AWS service prefix (e.g., s3, iam, ec2)",
166
+ help="AWS service prefix (e.g., s3, iam, ec2). Optional if --name includes service prefix.",
120
167
  )
121
168
  action_parser.add_argument(
122
169
  "--name",
123
- help="Specific action name (e.g., GetObject, CreateUser)",
170
+ nargs="*",
171
+ help="Action name(s) - can specify multiple (e.g., GetObject PutObject or s3:GetObject dynamodb:Query). "
172
+ "Supports wildcards (e.g., 's3:Get*'). If service prefix included, --service is optional.",
124
173
  )
125
174
  action_parser.add_argument(
126
175
  "--access-level",
@@ -142,6 +191,23 @@ note:
142
191
  help="Output format (default: json)",
143
192
  )
144
193
 
194
+ # Output field filters - when specified, only show these fields
195
+ action_parser.add_argument(
196
+ "--show-condition-keys",
197
+ action="store_true",
198
+ help="Show only condition keys for each action",
199
+ )
200
+ action_parser.add_argument(
201
+ "--show-resource-types",
202
+ action="store_true",
203
+ help="Show only resource types for each action",
204
+ )
205
+ action_parser.add_argument(
206
+ "--show-access-level",
207
+ action="store_true",
208
+ help="Show only access level for each action",
209
+ )
210
+
145
211
  # ARN query
146
212
  arn_parser = subparsers.add_parser(
147
213
  "arn",
@@ -150,12 +216,11 @@ note:
150
216
  )
151
217
  arn_parser.add_argument(
152
218
  "--service",
153
- required=True,
154
- help="AWS service prefix (e.g., s3, iam, ec2)",
219
+ help="AWS service prefix (e.g., s3, iam, ec2). Optional if --name includes service prefix.",
155
220
  )
156
221
  arn_parser.add_argument(
157
222
  "--name",
158
- help="Specific ARN resource type name (e.g., bucket, role)",
223
+ help="ARN resource type (e.g., bucket or s3:bucket). If service prefix included, --service is optional.",
159
224
  )
160
225
  arn_parser.add_argument(
161
226
  "--list-arn-types",
@@ -177,12 +242,11 @@ note:
177
242
  )
178
243
  condition_parser.add_argument(
179
244
  "--service",
180
- required=True,
181
- help="AWS service prefix (e.g., s3, iam, ec2)",
245
+ help="AWS service prefix (e.g., s3, iam, ec2). Optional if --name includes service prefix.",
182
246
  )
183
247
  condition_parser.add_argument(
184
248
  "--name",
185
- help="Specific condition key name (e.g., s3:prefix, iam:PolicyArn)",
249
+ help="Condition key (e.g., prefix or s3:prefix). If service prefix included, --service is optional.",
186
250
  )
187
251
  condition_parser.add_argument(
188
252
  "--output",
@@ -191,15 +255,142 @@ note:
191
255
  help="Output format (default: json)",
192
256
  )
193
257
 
258
+ def _parse_service_and_name(
259
+ self, args: argparse.Namespace, query_type: str
260
+ ) -> tuple[str, str | None]:
261
+ """Parse service and name from arguments (single name - for ARN/condition queries).
262
+
263
+ Extracts service from --name if it contains a colon (e.g., 's3:GetObject').
264
+ If --name has a service prefix, it overrides --service.
265
+
266
+ Args:
267
+ args: Parsed arguments with optional service and name attributes.
268
+ query_type: Type of query ('action', 'arn', 'condition') for error messages.
269
+
270
+ Returns:
271
+ Tuple of (service, name) where name may be None.
272
+
273
+ Raises:
274
+ ValueError: If neither --service nor service prefix in --name is provided.
275
+ """
276
+ service = getattr(args, "service", None)
277
+ raw_name = getattr(args, "name", None)
278
+
279
+ # Handle list from nargs='*' - take first element for non-action queries
280
+ if isinstance(raw_name, list):
281
+ name = raw_name[0] if raw_name else None
282
+ else:
283
+ name = raw_name
284
+
285
+ # If name contains a colon, extract service from it
286
+ if name and ":" in name:
287
+ parts = name.split(":", 1)
288
+ extracted_service = parts[0]
289
+ extracted_name = parts[1] if len(parts) > 1 else None
290
+
291
+ # Use extracted service if --service wasn't provided
292
+ if not service:
293
+ service = extracted_service
294
+ # If both provided, prefer the one in --name for consistency
295
+ elif service != extracted_service:
296
+ logger.warning(
297
+ f"Service from --name '{extracted_service}' differs from --service '{service}'. "
298
+ f"Using '{extracted_service}' from --name."
299
+ )
300
+ service = extracted_service
301
+
302
+ name = extracted_name
303
+
304
+ # Validate that we have a service
305
+ if not service:
306
+ raise ValueError(
307
+ f"--service is required when --name doesn't include service prefix. "
308
+ f"Use '--service <service>' or '--name <service>:<{query_type}>'"
309
+ )
310
+
311
+ return service, name
312
+
313
+ def _parse_action_names(self, args: argparse.Namespace) -> list[tuple[str, str | None, bool]]:
314
+ """Parse multiple action names from arguments.
315
+
316
+ Handles the --name argument which can contain multiple action names,
317
+ each optionally with a service prefix. Detects wildcard patterns.
318
+
319
+ Args:
320
+ args: Parsed arguments with optional service and name attributes.
321
+
322
+ Returns:
323
+ List of (service, action_name, is_wildcard) tuples.
324
+ action_name is None when listing all actions for a service.
325
+
326
+ Raises:
327
+ ValueError: If service cannot be determined for an action.
328
+ """
329
+ default_service = getattr(args, "service", None)
330
+ raw_names = getattr(args, "name", None)
331
+
332
+ # Normalize names to a list (handles both string and list inputs)
333
+ if raw_names is None:
334
+ names: list[str] = []
335
+ elif isinstance(raw_names, str):
336
+ # Single string (backwards compatibility with tests)
337
+ names = [raw_names]
338
+ else:
339
+ # List from nargs='*'
340
+ names = list(raw_names)
341
+
342
+ # If no names provided, return service with None name (list all)
343
+ if not names:
344
+ if not default_service:
345
+ raise ValueError(
346
+ "--service is required when --name is not provided. "
347
+ "Use '--service <service>' to list all actions."
348
+ )
349
+ return [(default_service, None, False)]
350
+
351
+ results: list[tuple[str, str | None, bool]] = []
352
+ for name in names:
353
+ service = default_service
354
+ action_name: str | None = name
355
+
356
+ # If name contains a colon, extract service from it
357
+ if ":" in name:
358
+ parts = name.split(":", 1)
359
+ service = parts[0]
360
+ action_name = parts[1] if parts[1] else None
361
+
362
+ # Validate that we have a service
363
+ if not service:
364
+ raise ValueError(
365
+ f"--service is required for '{name}' (no service prefix). "
366
+ f"Use '--service <service>' or '<service>:{name}'"
367
+ )
368
+
369
+ # Detect if this is a wildcard pattern
370
+ is_wildcard = action_name is not None and ("*" in action_name or "?" in action_name)
371
+
372
+ results.append((service, action_name, is_wildcard))
373
+
374
+ return results
375
+
194
376
  async def execute(self, args: argparse.Namespace) -> int:
195
377
  """Execute query command."""
196
378
  try:
197
379
  async with AWSServiceFetcher(prefetch_common=False) as fetcher:
198
380
  if args.query_type == "action":
381
+ # Use new multi-action parsing for action queries
199
382
  result = await self._query_action_table(fetcher, args)
200
383
  elif args.query_type == "arn":
384
+ # Parse service and name for ARN queries (single name)
385
+ service, name = self._parse_service_and_name(args, args.query_type)
386
+ args.service = service
387
+ args.name = name
201
388
  result = await self._query_arn_table(fetcher, args)
202
389
  elif args.query_type == "condition":
390
+ # Parse service and name for condition queries (single name)
391
+ service, name = self._parse_service_and_name(args, args.query_type)
392
+ args.service = service
393
+ args.name = name
203
394
  result = await self._query_condition_table(fetcher, args)
204
395
  else:
205
396
  logger.error(f"Unknown query type: {args.query_type}")
@@ -242,44 +433,253 @@ note:
242
433
  # Default to read if none of the above
243
434
  return "read"
244
435
 
436
+ def _get_field_filters(self, args: argparse.Namespace) -> set[str] | None:
437
+ """Extract field filters from arguments.
438
+
439
+ Returns:
440
+ Set of fields to include, or None if no filters specified (show all).
441
+ Valid fields: 'condition_keys', 'resource_types', 'access_level'
442
+ """
443
+ fields: set[str] = set()
444
+
445
+ if getattr(args, "show_condition_keys", False):
446
+ fields.add("condition_keys")
447
+ if getattr(args, "show_resource_types", False):
448
+ fields.add("resource_types")
449
+ if getattr(args, "show_access_level", False):
450
+ fields.add("access_level")
451
+
452
+ return fields if fields else None
453
+
245
454
  async def _query_action_table(
246
455
  self, fetcher: AWSServiceFetcher, args: argparse.Namespace
247
456
  ) -> dict[str, Any] | list[dict[str, Any]]:
248
- """Query action table."""
249
- service_detail = await fetcher.fetch_service_by_name(args.service)
457
+ """Query action table with support for multiple actions and wildcards.
250
458
 
251
- # If specific action requested, return its details
252
- if args.name:
253
- action_name = args.name
254
- # Try case-insensitive lookup
255
- action_detail = None
256
- for key, detail in service_detail.actions.items():
257
- if key.lower() == action_name.lower():
258
- action_detail = detail
259
- break
459
+ Optimized for speed by:
460
+ - Grouping actions by service to minimize API calls
461
+ - Fetching service definitions in parallel
462
+ - Expanding wildcards in parallel
463
+ - Pre-building lowercase lookup dicts for O(1) case-insensitive matching
464
+ """
465
+ # Parse all action names
466
+ parsed_actions = self._parse_action_names(args)
467
+
468
+ # Get field filters if any
469
+ fields = self._get_field_filters(args)
470
+
471
+ # Check if this is a "list all" query (single service, no name)
472
+ if len(parsed_actions) == 1 and parsed_actions[0][1] is None:
473
+ service = parsed_actions[0][0]
474
+ return await self._query_all_actions_for_service(fetcher, service, args)
475
+
476
+ # Group actions by service for efficient batching
477
+ service_actions: dict[str, list[tuple[str | None, bool]]] = defaultdict(list)
478
+ for service, action_name, is_wildcard in parsed_actions:
479
+ service_actions[service].append((action_name, is_wildcard))
480
+
481
+ # Fetch all service definitions in parallel
482
+ services = list(service_actions.keys())
483
+ service_details = await asyncio.gather(
484
+ *[fetcher.fetch_service_by_name(s) for s in services],
485
+ return_exceptions=True,
486
+ )
260
487
 
261
- if not action_detail:
262
- raise ValueError(f"Action '{args.name}' not found in service '{args.service}'")
488
+ # Build service -> detail mapping with lowercase lookup dicts for O(1) matching
489
+ service_detail_map: dict[str, Any] = {}
490
+ service_lowercase_map: dict[str, dict[str, str]] = {} # lowercase -> original key
491
+ for service, detail in zip(services, service_details):
492
+ if isinstance(detail, BaseException):
493
+ raise ValueError(f"Failed to fetch service '{service}': {detail}")
494
+ # detail is now narrowed to ServiceDetail
495
+ service_detail_map[service] = detail
496
+ # Pre-build lowercase lookup for O(1) case-insensitive matching
497
+ service_lowercase_map[service] = {k.lower(): k for k in detail.actions.keys()}
498
+
499
+ # Determine if this is a single exact action query (for backwards compatibility)
500
+ is_single_exact_query = (
501
+ len(parsed_actions) == 1 and not parsed_actions[0][2] # Not a wildcard
502
+ )
263
503
 
264
- access_level = self._get_access_level(action_detail)
265
- description = (
266
- action_detail.annotations.get("Description", "N/A")
267
- if action_detail.annotations
268
- else "N/A"
504
+ # Collect all wildcard patterns for parallel expansion
505
+ wildcard_patterns: list[tuple[str, str, str]] = [] # (service, action_name, pattern)
506
+ exact_actions: list[tuple[str, str]] = [] # (service, action_name)
507
+
508
+ for service, actions_list in service_actions.items():
509
+ for action_name, is_wildcard in actions_list:
510
+ if action_name is None:
511
+ continue
512
+ if is_wildcard:
513
+ wildcard_patterns.append((service, action_name, f"{service}:{action_name}"))
514
+ else:
515
+ exact_actions.append((service, action_name))
516
+
517
+ # Expand all wildcards in parallel
518
+ wildcard_results: dict[str, list[str]] = {}
519
+ errors: list[str] = []
520
+
521
+ if wildcard_patterns:
522
+ expansions = await asyncio.gather(
523
+ *[fetcher.expand_wildcard_action(p[2]) for p in wildcard_patterns],
524
+ return_exceptions=True,
269
525
  )
526
+ for (_svc, _action, pattern), expansion in zip(wildcard_patterns, expansions):
527
+ if isinstance(expansion, BaseException):
528
+ errors.append(f"Failed to expand '{pattern}': {expansion}")
529
+ continue
530
+ # expansion is now narrowed to list[str]
531
+ wildcard_results[pattern] = sorted(expansion)
532
+
533
+ # Process results with deduplication
534
+ results: list[dict[str, Any]] = []
535
+ seen_actions: set[str] = set() # Track seen action names to prevent duplicates
536
+
537
+ # Process wildcard expansions
538
+ for service, action_name, pattern in wildcard_patterns:
539
+ if pattern not in wildcard_results:
540
+ continue # Error was logged above
541
+ service_detail = service_detail_map[service]
542
+ for full_action in wildcard_results[pattern]:
543
+ # Skip duplicates (e.g., s3:Get* s3:Get* or overlapping patterns)
544
+ if full_action in seen_actions:
545
+ continue
546
+ seen_actions.add(full_action)
547
+
548
+ action_part = full_action.split(":")[1] if ":" in full_action else full_action
549
+ action_detail = service_detail.actions.get(action_part)
550
+ if action_detail:
551
+ results.append(
552
+ self._format_action_detail(
553
+ service,
554
+ action_detail,
555
+ simple=args.output == "text" and not fields,
556
+ include_service_prefix=True, # Always include for wildcards
557
+ fields=fields,
558
+ )
559
+ )
560
+ else:
561
+ results.append(
562
+ {
563
+ "action": full_action,
564
+ "access_level": "Unknown",
565
+ "description": "N/A",
566
+ }
567
+ )
568
+
569
+ # Process exact actions with O(1) case-insensitive lookup
570
+ for service, action_name in exact_actions:
571
+ service_detail = service_detail_map[service]
572
+ lowercase_map = service_lowercase_map[service]
573
+
574
+ # O(1) case-insensitive lookup
575
+ original_key = lowercase_map.get(action_name.lower())
576
+ if original_key:
577
+ # Build full action name for deduplication check
578
+ full_action = f"{service}:{original_key}"
579
+ # Skip if already seen (e.g., s3:GetObject with s3:Get* in same query)
580
+ if full_action in seen_actions:
581
+ continue
582
+ seen_actions.add(full_action)
583
+
584
+ action_detail = service_detail.actions[original_key]
585
+ results.append(
586
+ self._format_action_detail(
587
+ service,
588
+ action_detail,
589
+ # Simple mode only for lists without field filters, not single queries
590
+ simple=args.output == "text" and not is_single_exact_query and not fields,
591
+ # Backwards compat: single exact query returns action without service prefix
592
+ include_service_prefix=not is_single_exact_query,
593
+ fields=fields,
594
+ )
595
+ )
596
+ else:
597
+ errors.append(f"Action '{action_name}' not found in service '{service}'")
598
+
599
+ # If there were errors and no results, raise
600
+ if errors and not results:
601
+ raise ValueError("\n".join(errors))
602
+
603
+ # If there were some errors but also results, log warnings
604
+ if errors:
605
+ for error in errors:
606
+ logger.warning(error)
607
+
608
+ # Return single dict if only one result, otherwise list
609
+ if len(results) == 1:
610
+ return results[0]
611
+ return results
612
+
613
+ def _format_action_detail(
614
+ self,
615
+ service: str,
616
+ action_detail: Any,
617
+ simple: bool = False,
618
+ include_service_prefix: bool = True,
619
+ fields: set[str] | None = None,
620
+ ) -> dict[str, Any]:
621
+ """Format action detail for output.
622
+
623
+ Args:
624
+ service: Service prefix
625
+ action_detail: Action detail from service definition
626
+ simple: If True, return minimal format (for text output lists)
627
+ include_service_prefix: If True, include service prefix in action name
628
+ fields: Set of fields to include. If None, include all fields.
629
+ Valid values: 'condition_keys', 'resource_types', 'access_level'
630
+
631
+ Returns:
632
+ Formatted action dictionary
633
+ """
634
+ access_level = self._get_access_level(action_detail)
635
+ action_name = (
636
+ f"{service}:{action_detail.name}" if include_service_prefix else action_detail.name
637
+ )
270
638
 
271
- return {
272
- "service": args.service,
273
- "action": action_detail.name,
274
- "description": description,
275
- "access_level": access_level,
276
- "resource_types": [r.get("Name", "*") for r in (action_detail.resources or [])],
277
- "condition_keys": action_detail.action_condition_keys or [],
278
- }
639
+ # Simple mode: just action name (for text output of action lists)
640
+ if simple and not fields:
641
+ return {"action": f"{service}:{action_detail.name}"} # Always full name for text output
642
+
643
+ # Field filtering mode: action + only requested fields
644
+ if fields:
645
+ result: dict[str, Any] = {"action": f"{service}:{action_detail.name}"}
646
+ if "condition_keys" in fields:
647
+ result["condition_keys"] = action_detail.action_condition_keys or []
648
+ if "resource_types" in fields:
649
+ result["resource_types"] = [
650
+ r.get("Name", "*") for r in (action_detail.resources or [])
651
+ ]
652
+ if "access_level" in fields:
653
+ result["access_level"] = access_level
654
+ return result
655
+
656
+ # Full output mode
657
+ description = (
658
+ action_detail.annotations.get("Description", "N/A")
659
+ if action_detail.annotations
660
+ else "N/A"
661
+ )
662
+
663
+ return {
664
+ "service": service,
665
+ "action": action_name,
666
+ "description": description,
667
+ "access_level": access_level,
668
+ "resource_types": [r.get("Name", "*") for r in (action_detail.resources or [])],
669
+ "condition_keys": action_detail.action_condition_keys or [],
670
+ }
671
+
672
+ async def _query_all_actions_for_service(
673
+ self, fetcher: AWSServiceFetcher, service: str, args: argparse.Namespace
674
+ ) -> list[dict[str, Any]]:
675
+ """Query all actions for a service with optional filters."""
676
+ service_detail = await fetcher.fetch_service_by_name(service)
677
+
678
+ # Get field filters if any
679
+ fields = self._get_field_filters(args)
279
680
 
280
- # Filter actions based on criteria
281
681
  filtered_actions = []
282
- for action_name, action_detail in service_detail.actions.items():
682
+ for _action_name, action_detail in service_detail.actions.items():
283
683
  access_level = self._get_access_level(action_detail)
284
684
 
285
685
  # Apply filters
@@ -306,19 +706,15 @@ note:
306
706
  if args.condition not in condition_keys:
307
707
  continue
308
708
 
309
- description = (
310
- action_detail.annotations.get("Description", "N/A")
311
- if action_detail.annotations
312
- else "N/A"
313
- )
314
-
315
- # Add to filtered list
709
+ # Add to filtered list using _format_action_detail for consistency
316
710
  filtered_actions.append(
317
- {
318
- "action": f"{args.service}:{action_name}",
319
- "access_level": access_level,
320
- "description": description,
321
- }
711
+ self._format_action_detail(
712
+ service,
713
+ action_detail,
714
+ simple=args.output == "text" and not fields,
715
+ include_service_prefix=True,
716
+ fields=fields,
717
+ )
322
718
  )
323
719
 
324
720
  return filtered_actions
@@ -376,11 +772,23 @@ note:
376
772
  # If specific condition key requested
377
773
  if args.name:
378
774
  condition_key = None
775
+ search_name = args.name
776
+
777
+ # First try exact match
379
778
  for key, ck in service_detail.condition_keys.items():
380
- if key.lower() == args.name.lower():
779
+ if key.lower() == search_name.lower():
381
780
  condition_key = ck
382
781
  break
383
782
 
783
+ # If not found, try with service prefix (condition keys often include it)
784
+ # e.g., searching for "prefix" in s3 should find "s3:prefix"
785
+ if not condition_key and ":" not in search_name:
786
+ full_name = f"{args.service}:{search_name}"
787
+ for key, ck in service_detail.condition_keys.items():
788
+ if key.lower() == full_name.lower():
789
+ condition_key = ck
790
+ break
791
+
384
792
  if not condition_key:
385
793
  raise ValueError(
386
794
  f"Condition key '{args.name}' not found in service '{args.service}'"
@@ -418,6 +826,7 @@ note:
418
826
  Text format outputs only the essential information:
419
827
  - For lists of actions: one action per line (service:action format)
420
828
  - For specific action: action name followed by key details
829
+ - For filtered output: action name with only the requested fields
421
830
  - For ARNs: one ARN format per line
422
831
  - For condition keys: one condition key per line
423
832
  """
@@ -428,9 +837,17 @@ note:
428
837
 
429
838
  first_item = result[0]
430
839
  if "action" in first_item:
431
- # Action list
840
+ # Action list - check if we have filtered fields to show
841
+ has_filtered_fields = any(
842
+ k in first_item for k in ("condition_keys", "resource_types", "access_level")
843
+ )
432
844
  for item in result:
433
- print(item["action"])
845
+ if has_filtered_fields:
846
+ # Print action with filtered fields
847
+ self._print_action_with_fields(item)
848
+ else:
849
+ # Simple list: just action name
850
+ print(item["action"])
434
851
  elif "condition_key" in first_item:
435
852
  # Condition key list
436
853
  for item in result:
@@ -447,14 +864,7 @@ note:
447
864
  elif isinstance(result, dict):
448
865
  # Single item details
449
866
  if "action" in result:
450
- # Action details
451
- print(result["action"])
452
- if result.get("resource_types"):
453
- print(f" Resource types: {', '.join(result['resource_types'])}")
454
- if result.get("condition_keys"):
455
- print(f" Condition keys: {', '.join(result['condition_keys'])}")
456
- if result.get("access_level"):
457
- print(f" Access level: {result['access_level']}")
867
+ self._print_action_with_fields(result)
458
868
 
459
869
  elif "resource_type" in result:
460
870
  # ARN details
@@ -473,6 +883,20 @@ note:
473
883
  if result.get("description") and result["description"] != "N/A":
474
884
  print(f" Description: {result['description']}")
475
885
 
886
+ def _print_action_with_fields(self, item: dict[str, Any]) -> None:
887
+ """Print action with any available fields.
888
+
889
+ Args:
890
+ item: Action dict with 'action' key and optionally filtered fields.
891
+ """
892
+ print(item["action"])
893
+ if item.get("resource_types"):
894
+ print(f" Resource types: {', '.join(item['resource_types'])}")
895
+ if item.get("condition_keys"):
896
+ print(f" Condition keys: {', '.join(item['condition_keys'])}")
897
+ if item.get("access_level"):
898
+ print(f" Access level: {item['access_level']}")
899
+
476
900
 
477
901
  # For testing
478
902
  if __name__ == "__main__":