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
@@ -12,10 +12,14 @@ Example usage:
12
12
  # Re-export main classes for public API
13
13
  from iam_validator.core.aws_service.fetcher import AWSServiceFetcher
14
14
  from iam_validator.core.aws_service.patterns import CompiledPatterns
15
- from iam_validator.core.aws_service.validators import ConditionKeyValidationResult
15
+ from iam_validator.core.aws_service.validators import (
16
+ ConditionKeyValidationResult,
17
+ condition_key_in_list,
18
+ )
16
19
 
17
20
  __all__ = [
18
21
  "AWSServiceFetcher",
19
22
  "ConditionKeyValidationResult",
20
23
  "CompiledPatterns",
24
+ "condition_key_in_list",
21
25
  ]
@@ -70,6 +70,26 @@ class ServiceCacheManager:
70
70
 
71
71
  return None
72
72
 
73
+ async def get_stale(self, url: str | None = None, base_url: str = "") -> Any | None:
74
+ """Get from disk cache even if expired (stale data fallback).
75
+
76
+ Use this method to retrieve stale cache data when a fresh fetch fails.
77
+ The stale data can serve as a fallback to avoid complete failure.
78
+
79
+ Args:
80
+ url: URL for disk cache lookup
81
+ base_url: Base URL for service reference API (used for disk cache path)
82
+
83
+ Returns:
84
+ Cached data if found (regardless of TTL), None otherwise
85
+ """
86
+ if url and self._storage:
87
+ cached = self._storage.read_from_cache(url, base_url, allow_stale=True)
88
+ if cached is not None:
89
+ logger.info(f"Using stale cache fallback for URL: {url}")
90
+ return cached
91
+ return None
92
+
73
93
  async def set(
74
94
  self, cache_key: str, value: Any, url: str | None = None, base_url: str = ""
75
95
  ) -> None:
@@ -245,11 +245,21 @@ class AWSServiceFetcher:
245
245
  f"raw:{self.BASE_URL}", url=self.BASE_URL, base_url=self.BASE_URL
246
246
  )
247
247
  if data is None:
248
- data = await self._client.fetch(self.BASE_URL)
249
- # Cache the raw data
250
- await self._cache.set(
251
- f"raw:{self.BASE_URL}", data, url=self.BASE_URL, base_url=self.BASE_URL
252
- )
248
+ try:
249
+ data = await self._client.fetch(self.BASE_URL)
250
+ # Cache the raw data (this refreshes the disk cache file)
251
+ await self._cache.set(
252
+ f"raw:{self.BASE_URL}", data, url=self.BASE_URL, base_url=self.BASE_URL
253
+ )
254
+ except Exception as e: # pylint: disable=broad-exception-caught
255
+ # API fetch failed - try stale cache as fallback
256
+ logger.warning(f"API fetch failed for services list: {e}")
257
+ stale_data = await self._cache.get_stale(url=self.BASE_URL, base_url=self.BASE_URL)
258
+ if stale_data is not None:
259
+ logger.info("Using stale cache data for services list due to API failure")
260
+ data = stale_data
261
+ else:
262
+ raise
253
263
 
254
264
  if not isinstance(data, list):
255
265
  raise ValueError("Expected list of services from root endpoint")
@@ -332,12 +342,26 @@ class AWSServiceFetcher:
332
342
  f"raw:{service.url}", url=service.url, base_url=self.BASE_URL
333
343
  )
334
344
  if data is None:
335
- # Fetch service detail from API
336
- data = await self._client.fetch(service.url)
337
- # Cache the raw data
338
- await self._cache.set(
339
- f"raw:{service.url}", data, url=service.url, base_url=self.BASE_URL
340
- )
345
+ try:
346
+ # Fetch service detail from API
347
+ data = await self._client.fetch(service.url)
348
+ # Cache the raw data (this refreshes the disk cache file)
349
+ await self._cache.set(
350
+ f"raw:{service.url}", data, url=service.url, base_url=self.BASE_URL
351
+ )
352
+ except Exception as e: # pylint: disable=broad-exception-caught
353
+ # API fetch failed - try stale cache as fallback
354
+ logger.warning(f"API fetch failed for {service_name}: {e}")
355
+ stale_data = await self._cache.get_stale(
356
+ url=service.url, base_url=self.BASE_URL
357
+ )
358
+ if stale_data is not None:
359
+ logger.info(
360
+ f"Using stale cache data for {service_name} due to API failure"
361
+ )
362
+ data = stale_data
363
+ else:
364
+ raise
341
365
 
342
366
  # Validate and parse
343
367
  service_detail = ServiceDetail.model_validate(data)
@@ -421,6 +445,73 @@ class AWSServiceFetcher:
421
445
  service_detail = await self.fetch_service_by_name(service_prefix)
422
446
  return await self._validator.validate_action(action, service_detail, allow_wildcards)
423
447
 
448
+ async def validate_actions_batch(
449
+ self,
450
+ actions: list[str],
451
+ allow_wildcards: bool = True,
452
+ ) -> dict[str, tuple[bool, str | None, bool]]:
453
+ """Validate multiple IAM actions efficiently in batch.
454
+
455
+ Groups actions by service prefix and fetches each service definition once,
456
+ reducing network overhead when validating multiple actions.
457
+
458
+ Args:
459
+ actions: List of full action strings (e.g., ["s3:GetObject", "iam:CreateUser"])
460
+ allow_wildcards: Whether to allow wildcard actions
461
+
462
+ Returns:
463
+ Dictionary mapping action -> (is_valid, error_message, is_wildcard)
464
+
465
+ Example:
466
+ >>> async with AWSServiceFetcher() as fetcher:
467
+ ... results = await fetcher.validate_actions_batch([
468
+ ... "s3:GetObject",
469
+ ... "s3:PutObject",
470
+ ... "iam:CreateUser"
471
+ ... ])
472
+ ... for action, (is_valid, error, is_wildcard) in results.items():
473
+ ... if not is_valid:
474
+ ... print(f"Invalid: {action} - {error}")
475
+ """
476
+ if not actions:
477
+ return {}
478
+
479
+ # Group actions by service prefix
480
+ service_actions: dict[str, list[str]] = {}
481
+ for action in actions:
482
+ service_prefix, _ = self._parser.parse_action(action)
483
+ if service_prefix not in service_actions:
484
+ service_actions[service_prefix] = []
485
+ service_actions[service_prefix].append(action)
486
+
487
+ # Fetch all service definitions in parallel
488
+ service_details: dict[str, ServiceDetail] = {}
489
+ fetch_tasks = [self.fetch_service_by_name(service) for service in service_actions.keys()]
490
+ fetched = await asyncio.gather(*fetch_tasks, return_exceptions=True)
491
+
492
+ for service, result in zip(service_actions.keys(), fetched):
493
+ if isinstance(result, BaseException):
494
+ # Store None to indicate fetch failure
495
+ service_details[service] = None # type: ignore
496
+ else:
497
+ service_details[service] = result
498
+
499
+ # Validate all actions using cached service details
500
+ results: dict[str, tuple[bool, str | None, bool]] = {}
501
+ for action in actions:
502
+ service_prefix, _ = self._parser.parse_action(action)
503
+ service_detail = service_details.get(service_prefix)
504
+
505
+ if service_detail is None:
506
+ # Service fetch failed
507
+ results[action] = (False, f"Failed to fetch service '{service_prefix}'", False)
508
+ else:
509
+ results[action] = await self._validator.validate_action(
510
+ action, service_detail, allow_wildcards
511
+ )
512
+
513
+ return results
514
+
424
515
  def validate_arn(self, arn: str) -> tuple[bool, str | None]:
425
516
  """Validate ARN format.
426
517
 
@@ -466,6 +557,84 @@ class AWSServiceFetcher:
466
557
  action, condition_key, service_detail, resources
467
558
  )
468
559
 
560
+ async def is_condition_key_supported(
561
+ self,
562
+ action: str,
563
+ condition_key: str,
564
+ ) -> bool:
565
+ """Check if a condition key is supported for a specific action.
566
+
567
+ This checks two locations for the condition key:
568
+ 1. Action-level condition keys (ActionConditionKeys)
569
+ 2. Resource-level condition keys (for each resource the action operates on)
570
+
571
+ Returns True if the condition key is found in either location.
572
+
573
+ This is useful for determining if a condition provides meaningful
574
+ restrictions for an action, particularly for resource-scoping conditions
575
+ like aws:ResourceTag/*.
576
+
577
+ Args:
578
+ action: IAM action (e.g., "s3:GetObject", "ssm:StartSession")
579
+ condition_key: Condition key to check (e.g., "aws:ResourceTag/Env")
580
+
581
+ Returns:
582
+ True if the condition key is supported for this action
583
+
584
+ Example:
585
+ >>> async with AWSServiceFetcher() as fetcher:
586
+ ... # SSM StartSession has aws:ResourceTag in ActionConditionKeys
587
+ ... supported = await fetcher.is_condition_key_supported(
588
+ ... "ssm:StartSession", "aws:ResourceTag/Component"
589
+ ... )
590
+ ... print(f"Tag support: {supported}") # True
591
+ """
592
+ from iam_validator.core.aws_service.validators import ( # pylint: disable=import-outside-toplevel
593
+ condition_key_in_list,
594
+ )
595
+
596
+ try:
597
+ service_prefix, action_name = self._parser.parse_action(action)
598
+ except ValueError:
599
+ return False # Invalid action format
600
+
601
+ # Can't verify wildcard actions
602
+ if "*" in action_name or "?" in action_name:
603
+ return False
604
+
605
+ service_detail = await self.fetch_service_by_name(service_prefix)
606
+ if not service_detail:
607
+ return False
608
+
609
+ # Case-insensitive action lookup
610
+ action_detail = None
611
+ action_name_lower = action_name.lower()
612
+ for name, detail in service_detail.actions.items():
613
+ if name.lower() == action_name_lower:
614
+ action_detail = detail
615
+ break
616
+
617
+ if not action_detail:
618
+ return False
619
+
620
+ # Check 1: Action-level condition keys
621
+ if action_detail.action_condition_keys:
622
+ if condition_key_in_list(condition_key, action_detail.action_condition_keys):
623
+ return True
624
+
625
+ # Check 2: Resource-level condition keys
626
+ if action_detail.resources:
627
+ for res_ref in action_detail.resources:
628
+ resource_name = res_ref.get("Name")
629
+ if not resource_name:
630
+ continue
631
+ resource_type = service_detail.resources.get(resource_name)
632
+ if resource_type and resource_type.condition_keys:
633
+ if condition_key_in_list(condition_key, resource_type.condition_keys):
634
+ return True
635
+
636
+ return False
637
+
469
638
  # --- Parsing Methods (delegate to parser) ---
470
639
 
471
640
  def parse_action(self, action: str) -> tuple[str, str]:
@@ -150,15 +150,16 @@ class ServiceFileStorage:
150
150
 
151
151
  return self._cache_dir / filename
152
152
 
153
- def read_from_cache(self, url: str, base_url: str) -> Any | None:
153
+ def read_from_cache(self, url: str, base_url: str, allow_stale: bool = False) -> Any | None:
154
154
  """Read from disk cache with TTL checking.
155
155
 
156
156
  Args:
157
157
  url: URL to read from cache
158
158
  base_url: Base URL for service reference API
159
+ allow_stale: If True, return stale cache data even if expired
159
160
 
160
161
  Returns:
161
- Cached data if valid, None otherwise
162
+ Cached data if valid (or if allow_stale and data exists), None otherwise
162
163
  """
163
164
  if not self.enable_cache:
164
165
  return None
@@ -171,14 +172,21 @@ class ServiceFileStorage:
171
172
  try:
172
173
  # Check file modification time for TTL
173
174
  mtime = cache_path.stat().st_mtime
174
- if time.time() - mtime > self.cache_ttl:
175
- logger.debug(f"Cache expired for {url}")
176
- cache_path.unlink() # Remove expired cache
175
+ is_expired = time.time() - mtime > self.cache_ttl
176
+
177
+ if is_expired and not allow_stale:
178
+ # Cache expired - don't delete the file, just return None
179
+ # The file will be refreshed (overwritten) on next successful fetch
180
+ logger.debug(f"Cache expired for {url} (keeping file for refresh)")
177
181
  return None
178
182
 
179
183
  with open(cache_path, encoding="utf-8") as f:
180
184
  data = json.load(f)
181
- logger.debug(f"Disk cache hit for {url}")
185
+
186
+ if is_expired:
187
+ logger.debug(f"Disk cache stale hit for {url} (allow_stale=True)")
188
+ else:
189
+ logger.debug(f"Disk cache hit for {url}")
182
190
  return data
183
191
 
184
192
  except Exception as e: # pylint: disable=broad-exception-caught
@@ -13,7 +13,6 @@ from iam_validator.core.aws_service.parsers import ServiceParser
13
13
  from iam_validator.core.constants import (
14
14
  AWS_TAG_KEY_ALLOWED_CHARS,
15
15
  AWS_TAG_KEY_MAX_LENGTH,
16
- AWS_TAG_KEY_PLACEHOLDERS,
17
16
  )
18
17
  from iam_validator.core.models import ServiceDetail
19
18
 
@@ -46,42 +45,17 @@ def _is_valid_tag_key(tag_key: str) -> bool:
46
45
  return bool(_TAG_KEY_PATTERN.match(tag_key))
47
46
 
48
47
 
49
- def _matches_condition_key_pattern(condition_key: str, pattern: str) -> bool:
50
- """Check if a condition key matches a pattern with tag-key placeholders.
48
+ def condition_key_in_list(condition_key: str, condition_keys: list[str]) -> bool:
49
+ """Check if a condition key matches any key in the list, supporting patterns.
51
50
 
52
- AWS service definitions use patterns like:
53
- - `ssm:resourceTag/tag-key` or `ssm:resourceTag/${TagKey}` to match `ssm:resourceTag/owner`
51
+ AWS service definitions use patterns with tag-key placeholders like:
52
+ - `ssm:resourceTag/tag-key` to match `ssm:resourceTag/owner`
54
53
  - `aws:ResourceTag/${TagKey}` to match `aws:ResourceTag/Environment`
54
+ - `s3:RequestObjectTag/<key>` to match `s3:RequestObjectTag/Environment`
55
55
 
56
- Args:
57
- condition_key: The actual condition key from the policy (e.g., "ssm:resourceTag/owner")
58
- pattern: The pattern from AWS service definition (e.g., "ssm:resourceTag/tag-key")
59
-
60
- Returns:
61
- True if condition_key matches the pattern
62
- """
63
- # Exact match (fast path)
64
- if condition_key == pattern:
65
- return True
66
-
67
- # Check for tag-key placeholder patterns
68
- for tag_placeholder in AWS_TAG_KEY_PLACEHOLDERS:
69
- if tag_placeholder in pattern:
70
- # Extract the prefix before the placeholder
71
- prefix = pattern.split(tag_placeholder, 1)[0]
72
- prefix_with_slash = prefix + "/"
73
- # Check if condition_key starts with prefix and has a tag key after it
74
- if condition_key.startswith(prefix_with_slash):
75
- # Validate tag key format per AWS constraints
76
- tag_key = condition_key[len(prefix_with_slash) :]
77
- if _is_valid_tag_key(tag_key):
78
- return True
79
-
80
- return False
81
-
82
-
83
- def _condition_key_in_list(condition_key: str, condition_keys: list[str]) -> bool:
84
- """Check if a condition key matches any key in the list, supporting patterns.
56
+ Any pattern containing "/" is treated as a potential tag-key pattern where
57
+ the prefix before "/" must match exactly and the suffix after "/" in the
58
+ condition_key must be a valid AWS tag key.
85
59
 
86
60
  Args:
87
61
  condition_key: The condition key to check
@@ -94,13 +68,30 @@ def _condition_key_in_list(condition_key: str, condition_keys: list[str]) -> boo
94
68
  if condition_key in condition_keys:
95
69
  return True
96
70
 
97
- # Slower path: check patterns only if no exact match
71
+ # Check if condition_key could match a pattern (must contain "/")
72
+ if "/" not in condition_key:
73
+ return False
74
+
75
+ # Extract prefix and tag key from condition_key
76
+ cond_slash_idx = condition_key.rfind("/")
77
+ if cond_slash_idx <= 0:
78
+ return False
79
+
80
+ cond_prefix = condition_key[:cond_slash_idx]
81
+ tag_key = condition_key[cond_slash_idx + 1 :]
82
+
83
+ # Validate tag key format
84
+ if not _is_valid_tag_key(tag_key):
85
+ return False
86
+
87
+ # Check if any pattern has a matching prefix
98
88
  for pattern in condition_keys:
99
- # Skip exact matches (already checked above)
100
- if pattern == condition_key:
89
+ if "/" not in pattern:
101
90
  continue
102
- if _matches_condition_key_pattern(condition_key, pattern):
91
+ pattern_prefix = pattern[: pattern.rfind("/")]
92
+ if pattern_prefix == cond_prefix:
103
93
  return True
94
+
104
95
  return False
105
96
 
106
97
 
@@ -252,27 +243,35 @@ class ServiceValidator:
252
243
  _, action_name = self._parser.parse_action(action)
253
244
 
254
245
  # Check if it's a global condition key
246
+ # Note: Some aws: prefixed keys like aws:RequestTag/* and aws:ResourceTag/* are NOT
247
+ # global keys - they're action-specific or resource-specific. We'll check those later.
255
248
  is_global_key = False
256
249
  if condition_key.startswith("aws:"):
257
250
  global_conditions = get_global_conditions()
258
251
  if global_conditions.is_valid_global_key(condition_key):
259
252
  is_global_key = True
260
- else:
261
- return ConditionKeyValidationResult(
262
- is_valid=False,
263
- error_message=f"Invalid AWS global condition key: `{condition_key}`.",
264
- )
253
+ # If not a global key, continue to check action/resource-specific keys
254
+ # Don't return an error yet - aws:RequestTag, aws:ResourceTag are action-specific
265
255
 
266
256
  # Check service-specific condition keys (with pattern matching for tag keys)
267
- if service_detail.condition_keys and _condition_key_in_list(
268
- condition_key, list(service_detail.condition_keys.keys())
269
- ):
270
- return ConditionKeyValidationResult(is_valid=True)
257
+ # IMPORTANT: aws:RequestTag and aws:ResourceTag patterns in service-level keys
258
+ # are NOT universally valid for all actions. Skip them here - they'll be checked
259
+ # at action/resource level.
260
+ if service_detail.condition_keys:
261
+ # Check if it matches service-level keys, but exclude RequestTag/ResourceTag
262
+ if condition_key_in_list(condition_key, list(service_detail.condition_keys.keys())):
263
+ # If it's RequestTag or ResourceTag, don't return valid here - check action/resource level
264
+ if not (
265
+ condition_key.startswith("aws:RequestTag/")
266
+ or condition_key.startswith("aws:ResourceTag/")
267
+ ):
268
+ return ConditionKeyValidationResult(is_valid=True)
269
+ # For RequestTag/ResourceTag, continue to check action/resource level
271
270
 
272
271
  # Check action-specific condition keys
273
272
  if action_name in service_detail.actions:
274
273
  action_detail = service_detail.actions[action_name]
275
- if action_detail.action_condition_keys and _condition_key_in_list(
274
+ if action_detail.action_condition_keys and condition_key_in_list(
276
275
  condition_key, action_detail.action_condition_keys
277
276
  ):
278
277
  return ConditionKeyValidationResult(is_valid=True)
@@ -288,7 +287,7 @@ class ServiceValidator:
288
287
  # Look up resource type definition
289
288
  resource_type = service_detail.resources.get(resource_name)
290
289
  if resource_type and resource_type.condition_keys:
291
- if _condition_key_in_list(condition_key, resource_type.condition_keys):
290
+ if condition_key_in_list(condition_key, resource_type.condition_keys):
292
291
  return ConditionKeyValidationResult(is_valid=True)
293
292
 
294
293
  # If it's a global key but the action has specific condition keys defined,
@@ -307,8 +306,26 @@ class ServiceValidator:
307
306
  if is_global_key:
308
307
  return ConditionKeyValidationResult(is_valid=True)
309
308
 
310
- # Short error message
311
- error_msg = f"Condition key `{condition_key}` is not valid for action `{action}`"
309
+ # If we reach here, the condition key was not found in any valid location
310
+ # Check if it's an aws: prefixed key that's not global - provide specific error
311
+ if condition_key.startswith("aws:"):
312
+ # Special handling for aws:RequestTag and aws:ResourceTag patterns
313
+ if condition_key.startswith("aws:RequestTag/"):
314
+ error_msg = (
315
+ f"Condition key `{condition_key}` is not supported by action `{action}`. "
316
+ f"The `aws:RequestTag/${{TagKey}}` condition is only supported by actions that "
317
+ f"create or modify resources with tags. This action does not support tag operations."
318
+ )
319
+ elif condition_key.startswith("aws:ResourceTag/"):
320
+ error_msg = (
321
+ f"Condition key `{condition_key}` is not supported by the resources used by action `{action}`. "
322
+ f"The `aws:ResourceTag/${{TagKey}}` condition is only supported by resources that have tags."
323
+ )
324
+ else:
325
+ error_msg = f"Invalid AWS condition key: `{condition_key}`. This key is not a valid global condition key and is not supported by action `{action}`."
326
+ else:
327
+ # Short error message for non-aws: keys
328
+ error_msg = f"Condition key `{condition_key}` is not valid for action `{action}`"
312
329
 
313
330
  # Collect valid condition keys for this action
314
331
  valid_keys: set[str] = set()