iam-policy-validator 1.14.6__py3-none-any.whl → 1.15.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.
- {iam_policy_validator-1.14.6.dist-info → iam_policy_validator-1.15.0.dist-info}/METADATA +34 -23
- {iam_policy_validator-1.14.6.dist-info → iam_policy_validator-1.15.0.dist-info}/RECORD +42 -29
- iam_policy_validator-1.15.0.dist-info/entry_points.txt +4 -0
- iam_validator/__version__.py +1 -1
- iam_validator/checks/__init__.py +2 -0
- iam_validator/checks/action_validation.py +91 -27
- iam_validator/checks/not_action_not_resource.py +163 -0
- iam_validator/checks/resource_validation.py +132 -81
- iam_validator/checks/wildcard_resource.py +136 -6
- iam_validator/commands/__init__.py +3 -0
- iam_validator/commands/cache.py +66 -24
- iam_validator/commands/completion.py +94 -15
- iam_validator/commands/mcp.py +210 -0
- iam_validator/commands/query.py +489 -65
- iam_validator/core/aws_service/__init__.py +5 -1
- iam_validator/core/aws_service/cache.py +20 -0
- iam_validator/core/aws_service/fetcher.py +180 -11
- iam_validator/core/aws_service/storage.py +14 -6
- iam_validator/core/aws_service/validators.py +32 -41
- iam_validator/core/check_registry.py +100 -35
- iam_validator/core/config/aws_global_conditions.py +13 -0
- iam_validator/core/config/check_documentation.py +104 -51
- iam_validator/core/config/config_loader.py +39 -3
- iam_validator/core/config/defaults.py +6 -0
- iam_validator/core/constants.py +11 -4
- iam_validator/core/models.py +39 -14
- iam_validator/mcp/__init__.py +162 -0
- iam_validator/mcp/models.py +118 -0
- iam_validator/mcp/server.py +2928 -0
- iam_validator/mcp/session_config.py +319 -0
- iam_validator/mcp/templates/__init__.py +79 -0
- iam_validator/mcp/templates/builtin.py +856 -0
- iam_validator/mcp/tools/__init__.py +72 -0
- iam_validator/mcp/tools/generation.py +888 -0
- iam_validator/mcp/tools/org_config_tools.py +263 -0
- iam_validator/mcp/tools/query.py +395 -0
- iam_validator/mcp/tools/validation.py +376 -0
- iam_validator/sdk/__init__.py +64 -63
- iam_validator/sdk/context.py +3 -2
- iam_validator/sdk/policy_utils.py +31 -5
- iam_policy_validator-1.14.6.dist-info/entry_points.txt +0 -2
- {iam_policy_validator-1.14.6.dist-info → iam_policy_validator-1.15.0.dist-info}/WHEEL +0 -0
- {iam_policy_validator-1.14.6.dist-info → iam_policy_validator-1.15.0.dist-info}/licenses/LICENSE +0 -0
iam_validator/commands/query.py
CHANGED
|
@@ -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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
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
|
-
|
|
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="
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
"
|
|
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() ==
|
|
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
|
-
|
|
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
|
-
|
|
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__":
|