iam-policy-validator 1.14.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.0.dist-info/METADATA +782 -0
- iam_policy_validator-1.14.0.dist-info/RECORD +106 -0
- iam_policy_validator-1.14.0.dist-info/WHEEL +4 -0
- iam_policy_validator-1.14.0.dist-info/entry_points.txt +2 -0
- iam_policy_validator-1.14.0.dist-info/licenses/LICENSE +21 -0
- iam_validator/__init__.py +27 -0
- iam_validator/__main__.py +11 -0
- iam_validator/__version__.py +9 -0
- iam_validator/checks/__init__.py +45 -0
- iam_validator/checks/action_condition_enforcement.py +1442 -0
- iam_validator/checks/action_resource_matching.py +472 -0
- iam_validator/checks/action_validation.py +67 -0
- iam_validator/checks/condition_key_validation.py +88 -0
- iam_validator/checks/condition_type_mismatch.py +257 -0
- iam_validator/checks/full_wildcard.py +62 -0
- iam_validator/checks/mfa_condition_check.py +105 -0
- iam_validator/checks/policy_size.py +114 -0
- iam_validator/checks/policy_structure.py +556 -0
- iam_validator/checks/policy_type_validation.py +331 -0
- iam_validator/checks/principal_validation.py +708 -0
- iam_validator/checks/resource_validation.py +135 -0
- iam_validator/checks/sensitive_action.py +438 -0
- iam_validator/checks/service_wildcard.py +98 -0
- iam_validator/checks/set_operator_validation.py +153 -0
- iam_validator/checks/sid_uniqueness.py +146 -0
- iam_validator/checks/trust_policy_validation.py +509 -0
- iam_validator/checks/utils/__init__.py +17 -0
- iam_validator/checks/utils/action_parser.py +149 -0
- iam_validator/checks/utils/policy_level_checks.py +190 -0
- iam_validator/checks/utils/sensitive_action_matcher.py +293 -0
- iam_validator/checks/utils/wildcard_expansion.py +86 -0
- iam_validator/checks/wildcard_action.py +58 -0
- iam_validator/checks/wildcard_resource.py +374 -0
- iam_validator/commands/__init__.py +31 -0
- iam_validator/commands/analyze.py +549 -0
- iam_validator/commands/base.py +48 -0
- iam_validator/commands/cache.py +393 -0
- iam_validator/commands/completion.py +471 -0
- iam_validator/commands/download_services.py +255 -0
- iam_validator/commands/post_to_pr.py +86 -0
- iam_validator/commands/query.py +485 -0
- iam_validator/commands/validate.py +830 -0
- iam_validator/core/__init__.py +13 -0
- iam_validator/core/access_analyzer.py +671 -0
- iam_validator/core/access_analyzer_report.py +640 -0
- iam_validator/core/aws_fetcher.py +29 -0
- iam_validator/core/aws_service/__init__.py +21 -0
- iam_validator/core/aws_service/cache.py +108 -0
- iam_validator/core/aws_service/client.py +205 -0
- iam_validator/core/aws_service/fetcher.py +641 -0
- iam_validator/core/aws_service/parsers.py +149 -0
- iam_validator/core/aws_service/patterns.py +51 -0
- iam_validator/core/aws_service/storage.py +291 -0
- iam_validator/core/aws_service/validators.py +380 -0
- iam_validator/core/check_registry.py +679 -0
- iam_validator/core/cli.py +134 -0
- iam_validator/core/codeowners.py +245 -0
- iam_validator/core/condition_validators.py +626 -0
- iam_validator/core/config/__init__.py +81 -0
- iam_validator/core/config/aws_api.py +35 -0
- iam_validator/core/config/aws_global_conditions.py +160 -0
- iam_validator/core/config/category_suggestions.py +181 -0
- iam_validator/core/config/check_documentation.py +390 -0
- iam_validator/core/config/condition_requirements.py +258 -0
- iam_validator/core/config/config_loader.py +670 -0
- iam_validator/core/config/defaults.py +739 -0
- iam_validator/core/config/principal_requirements.py +421 -0
- iam_validator/core/config/sensitive_actions.py +672 -0
- iam_validator/core/config/service_principals.py +132 -0
- iam_validator/core/config/wildcards.py +127 -0
- iam_validator/core/constants.py +149 -0
- iam_validator/core/diff_parser.py +325 -0
- iam_validator/core/finding_fingerprint.py +131 -0
- iam_validator/core/formatters/__init__.py +27 -0
- iam_validator/core/formatters/base.py +147 -0
- iam_validator/core/formatters/console.py +68 -0
- iam_validator/core/formatters/csv.py +171 -0
- iam_validator/core/formatters/enhanced.py +481 -0
- iam_validator/core/formatters/html.py +672 -0
- iam_validator/core/formatters/json.py +33 -0
- iam_validator/core/formatters/markdown.py +64 -0
- iam_validator/core/formatters/sarif.py +251 -0
- iam_validator/core/ignore_patterns.py +297 -0
- iam_validator/core/ignore_processor.py +309 -0
- iam_validator/core/ignored_findings.py +400 -0
- iam_validator/core/label_manager.py +197 -0
- iam_validator/core/models.py +404 -0
- iam_validator/core/policy_checks.py +220 -0
- iam_validator/core/policy_loader.py +785 -0
- iam_validator/core/pr_commenter.py +780 -0
- iam_validator/core/report.py +942 -0
- iam_validator/integrations/__init__.py +28 -0
- iam_validator/integrations/github_integration.py +1821 -0
- iam_validator/integrations/ms_teams.py +442 -0
- iam_validator/sdk/__init__.py +220 -0
- iam_validator/sdk/arn_matching.py +382 -0
- iam_validator/sdk/context.py +222 -0
- iam_validator/sdk/exceptions.py +48 -0
- iam_validator/sdk/helpers.py +177 -0
- iam_validator/sdk/policy_utils.py +451 -0
- iam_validator/sdk/query_utils.py +454 -0
- iam_validator/sdk/shortcuts.py +283 -0
- iam_validator/utils/__init__.py +35 -0
- iam_validator/utils/cache.py +105 -0
- iam_validator/utils/regex.py +205 -0
- iam_validator/utils/terminal.py +22 -0
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
"""AWS Service Fetcher - Main orchestrator for AWS service data retrieval.
|
|
2
|
+
|
|
3
|
+
This module provides the main AWSServiceFetcher class that coordinates between
|
|
4
|
+
HTTP client, caching, storage, parsing, and validation components.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import logging
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from iam_validator.core import constants
|
|
13
|
+
from iam_validator.core.aws_service.cache import ServiceCacheManager
|
|
14
|
+
from iam_validator.core.aws_service.client import AWSServiceClient
|
|
15
|
+
from iam_validator.core.aws_service.parsers import ServiceParser
|
|
16
|
+
from iam_validator.core.aws_service.storage import ServiceFileStorage
|
|
17
|
+
from iam_validator.core.aws_service.validators import (
|
|
18
|
+
ConditionKeyValidationResult,
|
|
19
|
+
ServiceValidator,
|
|
20
|
+
)
|
|
21
|
+
from iam_validator.core.config import AWS_SERVICE_REFERENCE_BASE_URL
|
|
22
|
+
from iam_validator.core.models import ServiceDetail, ServiceInfo
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AWSServiceFetcher:
|
|
28
|
+
"""Fetches and validates AWS service information with caching.
|
|
29
|
+
|
|
30
|
+
This is the main entry point for AWS service data operations.
|
|
31
|
+
Coordinates between HTTP client, caching, storage, and validation.
|
|
32
|
+
|
|
33
|
+
Features:
|
|
34
|
+
- Multi-layer caching (memory LRU + disk with TTL)
|
|
35
|
+
- Service pre-fetching for common AWS services
|
|
36
|
+
- Request batching and coalescing
|
|
37
|
+
- Offline mode support with local AWS service files
|
|
38
|
+
- HTTP/2 connection pooling
|
|
39
|
+
- Automatic retry with exponential backoff
|
|
40
|
+
|
|
41
|
+
Example:
|
|
42
|
+
>>> async with AWSServiceFetcher() as fetcher:
|
|
43
|
+
... # Fetch service list
|
|
44
|
+
... services = await fetcher.fetch_services()
|
|
45
|
+
...
|
|
46
|
+
... # Fetch specific service details
|
|
47
|
+
... s3_service = await fetcher.fetch_service_by_name("s3")
|
|
48
|
+
...
|
|
49
|
+
... # Validate actions
|
|
50
|
+
... is_valid, error, is_wildcard = await fetcher.validate_action("s3:GetObject")
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
BASE_URL = AWS_SERVICE_REFERENCE_BASE_URL
|
|
54
|
+
|
|
55
|
+
# Common AWS services to pre-fetch
|
|
56
|
+
# All other services will be fetched on-demand (lazy loading if found in policies)
|
|
57
|
+
COMMON_SERVICES = [
|
|
58
|
+
"acm",
|
|
59
|
+
"apigateway",
|
|
60
|
+
"autoscaling",
|
|
61
|
+
"backup",
|
|
62
|
+
"batch",
|
|
63
|
+
"bedrock",
|
|
64
|
+
"cloudformation",
|
|
65
|
+
"cloudfront",
|
|
66
|
+
"cloudtrail",
|
|
67
|
+
"cloudwatch",
|
|
68
|
+
"config",
|
|
69
|
+
"dynamodb",
|
|
70
|
+
"ec2-instance-connect",
|
|
71
|
+
"ec2",
|
|
72
|
+
"ecr",
|
|
73
|
+
"ecs",
|
|
74
|
+
"eks",
|
|
75
|
+
"elasticache",
|
|
76
|
+
"elasticloadbalancing",
|
|
77
|
+
"events",
|
|
78
|
+
"firehose",
|
|
79
|
+
"glacier",
|
|
80
|
+
"glue",
|
|
81
|
+
"guardduty",
|
|
82
|
+
"iam",
|
|
83
|
+
"imagebuilder",
|
|
84
|
+
"inspector2",
|
|
85
|
+
"kinesis",
|
|
86
|
+
"kms",
|
|
87
|
+
"lambda",
|
|
88
|
+
"logs",
|
|
89
|
+
"rds",
|
|
90
|
+
"route53",
|
|
91
|
+
"s3",
|
|
92
|
+
"scheduler",
|
|
93
|
+
"secretsmanager",
|
|
94
|
+
"securityhub",
|
|
95
|
+
"sns",
|
|
96
|
+
"sqs",
|
|
97
|
+
"sts",
|
|
98
|
+
"support",
|
|
99
|
+
"waf",
|
|
100
|
+
"wafv2",
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
# Default concurrency limits
|
|
104
|
+
DEFAULT_MAX_CONCURRENT_REQUESTS = 10
|
|
105
|
+
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
timeout: float = constants.DEFAULT_HTTP_TIMEOUT_SECONDS,
|
|
109
|
+
retries: int = 3,
|
|
110
|
+
enable_cache: bool = True,
|
|
111
|
+
cache_ttl: int = constants.DEFAULT_CACHE_TTL_SECONDS,
|
|
112
|
+
memory_cache_size: int = 256,
|
|
113
|
+
connection_pool_size: int = 50,
|
|
114
|
+
keepalive_connections: int = 20,
|
|
115
|
+
prefetch_common: bool = True,
|
|
116
|
+
cache_dir: Path | str | None = None,
|
|
117
|
+
aws_services_dir: Path | str | None = None,
|
|
118
|
+
max_concurrent_requests: int = DEFAULT_MAX_CONCURRENT_REQUESTS,
|
|
119
|
+
):
|
|
120
|
+
"""Initialize AWS service fetcher.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
timeout: Request timeout in seconds
|
|
124
|
+
retries: Number of retries for failed requests
|
|
125
|
+
enable_cache: Enable persistent disk caching
|
|
126
|
+
cache_ttl: Cache time-to-live in seconds
|
|
127
|
+
memory_cache_size: Size of in-memory LRU cache
|
|
128
|
+
connection_pool_size: HTTP connection pool size
|
|
129
|
+
keepalive_connections: Number of keepalive connections
|
|
130
|
+
prefetch_common: Prefetch common AWS services
|
|
131
|
+
cache_dir: Custom cache directory path
|
|
132
|
+
aws_services_dir: Directory containing pre-downloaded AWS service JSON files.
|
|
133
|
+
When set, the fetcher will load services from local files
|
|
134
|
+
instead of making API calls. Directory should contain:
|
|
135
|
+
- _services.json: List of all services
|
|
136
|
+
- {service}.json: Individual service files (e.g., s3.json)
|
|
137
|
+
max_concurrent_requests: Maximum number of concurrent HTTP requests (default: 10)
|
|
138
|
+
"""
|
|
139
|
+
self.prefetch_common = prefetch_common
|
|
140
|
+
self.aws_services_dir = Path(aws_services_dir) if aws_services_dir else None
|
|
141
|
+
self._prefetched_services: set[str] = set()
|
|
142
|
+
# Semaphore for limiting concurrent requests
|
|
143
|
+
self._request_semaphore = asyncio.Semaphore(max_concurrent_requests)
|
|
144
|
+
|
|
145
|
+
# Initialize storage component
|
|
146
|
+
self._storage = ServiceFileStorage(
|
|
147
|
+
cache_dir=cache_dir,
|
|
148
|
+
aws_services_dir=aws_services_dir,
|
|
149
|
+
cache_ttl=cache_ttl,
|
|
150
|
+
enable_cache=enable_cache,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# Initialize cache manager
|
|
154
|
+
self._cache = ServiceCacheManager(
|
|
155
|
+
memory_cache_size=memory_cache_size,
|
|
156
|
+
cache_ttl=cache_ttl,
|
|
157
|
+
storage=self._storage if enable_cache else None,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# Initialize HTTP client
|
|
161
|
+
self._client = AWSServiceClient(
|
|
162
|
+
base_url=self.BASE_URL,
|
|
163
|
+
timeout=timeout,
|
|
164
|
+
retries=retries,
|
|
165
|
+
connection_pool_size=connection_pool_size,
|
|
166
|
+
keepalive_connections=keepalive_connections,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# Initialize parser and validator
|
|
170
|
+
self._parser = ServiceParser()
|
|
171
|
+
self._validator = ServiceValidator(parser=self._parser)
|
|
172
|
+
|
|
173
|
+
async def __aenter__(self) -> "AWSServiceFetcher":
|
|
174
|
+
"""Async context manager entry."""
|
|
175
|
+
await self._client.__aenter__()
|
|
176
|
+
|
|
177
|
+
# Pre-fetch common services if enabled
|
|
178
|
+
if self.prefetch_common:
|
|
179
|
+
await self._prefetch_common_services()
|
|
180
|
+
|
|
181
|
+
return self
|
|
182
|
+
|
|
183
|
+
async def __aexit__(
|
|
184
|
+
self,
|
|
185
|
+
exc_type: type[BaseException] | None,
|
|
186
|
+
exc_val: BaseException | None,
|
|
187
|
+
exc_tb: Any,
|
|
188
|
+
) -> None:
|
|
189
|
+
"""Async context manager exit."""
|
|
190
|
+
await self._client.__aexit__(exc_type, exc_val, exc_tb)
|
|
191
|
+
|
|
192
|
+
async def _prefetch_common_services(self) -> None:
|
|
193
|
+
"""Pre-fetch commonly used AWS services for better performance."""
|
|
194
|
+
logger.info(f"Pre-fetching {len(self.COMMON_SERVICES)} common AWS services...")
|
|
195
|
+
|
|
196
|
+
# First, fetch the services list once to populate the cache
|
|
197
|
+
# This prevents all concurrent calls from fetching the same list
|
|
198
|
+
await self.fetch_services()
|
|
199
|
+
|
|
200
|
+
async def fetch_service(name: str) -> None:
|
|
201
|
+
try:
|
|
202
|
+
await self.fetch_service_by_name(name)
|
|
203
|
+
self._prefetched_services.add(name)
|
|
204
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
205
|
+
logger.warning(f"Failed to prefetch service {name}: {e}")
|
|
206
|
+
|
|
207
|
+
# Fetch in batches to avoid overwhelming the API
|
|
208
|
+
batch_size = 5
|
|
209
|
+
for i in range(0, len(self.COMMON_SERVICES), batch_size):
|
|
210
|
+
batch = self.COMMON_SERVICES[i : i + batch_size]
|
|
211
|
+
await asyncio.gather(*[fetch_service(name) for name in batch])
|
|
212
|
+
|
|
213
|
+
logger.info(f"Pre-fetched {len(self._prefetched_services)} services successfully")
|
|
214
|
+
|
|
215
|
+
async def fetch_services(self) -> list[ServiceInfo]:
|
|
216
|
+
"""Fetch list of AWS services with caching.
|
|
217
|
+
|
|
218
|
+
When aws_services_dir is set, loads from local _services.json file.
|
|
219
|
+
Otherwise, fetches from AWS API.
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
List of ServiceInfo objects
|
|
223
|
+
|
|
224
|
+
Example:
|
|
225
|
+
>>> async with AWSServiceFetcher() as fetcher:
|
|
226
|
+
... services = await fetcher.fetch_services()
|
|
227
|
+
... print(f"Found {len(services)} AWS services")
|
|
228
|
+
"""
|
|
229
|
+
# Check if we have the parsed services list in cache
|
|
230
|
+
services_cache_key = "parsed_services_list"
|
|
231
|
+
cached_services = await self._cache.get(services_cache_key)
|
|
232
|
+
if cached_services is not None and isinstance(cached_services, list):
|
|
233
|
+
logger.debug(f"Retrieved {len(cached_services)} services from parsed cache")
|
|
234
|
+
return cached_services
|
|
235
|
+
|
|
236
|
+
# Load from local file if aws_services_dir is set
|
|
237
|
+
if self.aws_services_dir:
|
|
238
|
+
loaded_services = self._storage.load_services_from_file()
|
|
239
|
+
# Cache the loaded services
|
|
240
|
+
await self._cache.set(services_cache_key, loaded_services)
|
|
241
|
+
return loaded_services
|
|
242
|
+
|
|
243
|
+
# Not in parsed cache, check disk cache then fetch from API
|
|
244
|
+
data = await self._cache.get(
|
|
245
|
+
f"raw:{self.BASE_URL}", url=self.BASE_URL, base_url=self.BASE_URL
|
|
246
|
+
)
|
|
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
|
+
)
|
|
253
|
+
|
|
254
|
+
if not isinstance(data, list):
|
|
255
|
+
raise ValueError("Expected list of services from root endpoint")
|
|
256
|
+
|
|
257
|
+
services: list[ServiceInfo] = []
|
|
258
|
+
for item in data:
|
|
259
|
+
if isinstance(item, dict):
|
|
260
|
+
service = item.get("service")
|
|
261
|
+
url = item.get("url")
|
|
262
|
+
if service and url:
|
|
263
|
+
services.append(ServiceInfo(service=str(service), url=str(url)))
|
|
264
|
+
|
|
265
|
+
# Cache the parsed services list (memory only)
|
|
266
|
+
await self._cache.set(services_cache_key, services)
|
|
267
|
+
|
|
268
|
+
# Log only on first fetch (when parsed cache was empty)
|
|
269
|
+
logger.info(f"Fetched and parsed {len(services)} services from AWS API")
|
|
270
|
+
return services
|
|
271
|
+
|
|
272
|
+
async def fetch_service_by_name(self, service_name: str) -> ServiceDetail:
|
|
273
|
+
"""Fetch service detail with optimized caching.
|
|
274
|
+
|
|
275
|
+
When aws_services_dir is set, loads from local {service}.json file.
|
|
276
|
+
Otherwise, fetches from AWS API.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
service_name: Name of the service (case-insensitive, e.g., "s3", "iam")
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
ServiceDetail object with full service definition
|
|
283
|
+
|
|
284
|
+
Raises:
|
|
285
|
+
ValueError: If service is not found
|
|
286
|
+
|
|
287
|
+
Example:
|
|
288
|
+
>>> async with AWSServiceFetcher() as fetcher:
|
|
289
|
+
... s3_service = await fetcher.fetch_service_by_name("s3")
|
|
290
|
+
... print(f"S3 has {len(s3_service.actions)} actions")
|
|
291
|
+
"""
|
|
292
|
+
# Normalize service name
|
|
293
|
+
service_name_lower = service_name.lower()
|
|
294
|
+
|
|
295
|
+
# Check memory cache with service name as key
|
|
296
|
+
cache_key = f"service:{service_name_lower}"
|
|
297
|
+
cached_detail = await self._cache.get(cache_key)
|
|
298
|
+
if isinstance(cached_detail, ServiceDetail):
|
|
299
|
+
logger.debug(f"Memory cache hit for service {service_name}")
|
|
300
|
+
return cached_detail
|
|
301
|
+
|
|
302
|
+
# Load from local file if aws_services_dir is set
|
|
303
|
+
if self.aws_services_dir:
|
|
304
|
+
try:
|
|
305
|
+
service_detail = self._storage.load_service_from_file(service_name_lower)
|
|
306
|
+
# Cache the loaded service
|
|
307
|
+
await self._cache.set(cache_key, service_detail)
|
|
308
|
+
return service_detail
|
|
309
|
+
except FileNotFoundError:
|
|
310
|
+
# Try to find the service in services.json to get proper name
|
|
311
|
+
services = await self.fetch_services()
|
|
312
|
+
for service in services:
|
|
313
|
+
if service.service.lower() == service_name_lower:
|
|
314
|
+
# Try with the exact service name from services.json
|
|
315
|
+
try:
|
|
316
|
+
service_detail = self._storage.load_service_from_file(service.service)
|
|
317
|
+
await self._cache.set(cache_key, service_detail)
|
|
318
|
+
return service_detail
|
|
319
|
+
except FileNotFoundError:
|
|
320
|
+
pass
|
|
321
|
+
raise ValueError(
|
|
322
|
+
f"Service `{service_name}` not found in {self.aws_services_dir}"
|
|
323
|
+
) from FileNotFoundError
|
|
324
|
+
|
|
325
|
+
# Fetch service list and find URL from API
|
|
326
|
+
services = await self.fetch_services()
|
|
327
|
+
|
|
328
|
+
for service in services:
|
|
329
|
+
if service.service.lower() == service_name_lower:
|
|
330
|
+
# Check disk cache first, then fetch from API
|
|
331
|
+
data = await self._cache.get(
|
|
332
|
+
f"raw:{service.url}", url=service.url, base_url=self.BASE_URL
|
|
333
|
+
)
|
|
334
|
+
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
|
+
)
|
|
341
|
+
|
|
342
|
+
# Validate and parse
|
|
343
|
+
service_detail = ServiceDetail.model_validate(data)
|
|
344
|
+
|
|
345
|
+
# Cache with service name as key (memory only)
|
|
346
|
+
await self._cache.set(cache_key, service_detail)
|
|
347
|
+
|
|
348
|
+
return service_detail
|
|
349
|
+
|
|
350
|
+
raise ValueError(f"Service `{service_name}` not found")
|
|
351
|
+
|
|
352
|
+
async def fetch_multiple_services(self, service_names: list[str]) -> dict[str, ServiceDetail]:
|
|
353
|
+
"""Fetch multiple services concurrently with controlled parallelism.
|
|
354
|
+
|
|
355
|
+
Uses a semaphore to limit concurrent requests and prevent overwhelming
|
|
356
|
+
the AWS service reference API.
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
service_names: List of service names to fetch
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
Dictionary mapping service names to ServiceDetail objects
|
|
363
|
+
|
|
364
|
+
Raises:
|
|
365
|
+
Exception: If any service fetch fails
|
|
366
|
+
|
|
367
|
+
Example:
|
|
368
|
+
>>> async with AWSServiceFetcher() as fetcher:
|
|
369
|
+
... services = await fetcher.fetch_multiple_services(["s3", "iam", "ec2"])
|
|
370
|
+
... print(f"Fetched {len(services)} services")
|
|
371
|
+
"""
|
|
372
|
+
|
|
373
|
+
async def fetch_single(name: str) -> tuple[str, ServiceDetail]:
|
|
374
|
+
# Use semaphore to limit concurrent requests
|
|
375
|
+
async with self._request_semaphore:
|
|
376
|
+
try:
|
|
377
|
+
detail = await self.fetch_service_by_name(name)
|
|
378
|
+
return name, detail
|
|
379
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
380
|
+
logger.error(f"Failed to fetch service {name}: {e}")
|
|
381
|
+
raise
|
|
382
|
+
|
|
383
|
+
# Fetch all services concurrently (semaphore controls parallelism)
|
|
384
|
+
tasks = [fetch_single(name) for name in service_names]
|
|
385
|
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
386
|
+
|
|
387
|
+
services: dict[str, ServiceDetail] = {}
|
|
388
|
+
for i, result in enumerate(results):
|
|
389
|
+
if isinstance(result, Exception):
|
|
390
|
+
logger.error(f"Failed to fetch service {service_names[i]}: {result}")
|
|
391
|
+
raise result
|
|
392
|
+
if isinstance(result, tuple):
|
|
393
|
+
name, detail = result
|
|
394
|
+
services[name] = detail
|
|
395
|
+
|
|
396
|
+
return services
|
|
397
|
+
|
|
398
|
+
# --- Validation Methods (delegate to validator) ---
|
|
399
|
+
|
|
400
|
+
async def validate_action(
|
|
401
|
+
self,
|
|
402
|
+
action: str,
|
|
403
|
+
allow_wildcards: bool = True,
|
|
404
|
+
) -> tuple[bool, str | None, bool]:
|
|
405
|
+
"""Validate IAM action.
|
|
406
|
+
|
|
407
|
+
Args:
|
|
408
|
+
action: Full action string (e.g., "s3:GetObject", "iam:CreateUser")
|
|
409
|
+
allow_wildcards: Whether to allow wildcard actions
|
|
410
|
+
|
|
411
|
+
Returns:
|
|
412
|
+
Tuple of (is_valid, error_message, is_wildcard)
|
|
413
|
+
|
|
414
|
+
Example:
|
|
415
|
+
>>> async with AWSServiceFetcher() as fetcher:
|
|
416
|
+
... is_valid, error, is_wildcard = await fetcher.validate_action("s3:GetObject")
|
|
417
|
+
... if not is_valid:
|
|
418
|
+
... print(f"Invalid action: {error}")
|
|
419
|
+
"""
|
|
420
|
+
service_prefix, _ = self._parser.parse_action(action)
|
|
421
|
+
service_detail = await self.fetch_service_by_name(service_prefix)
|
|
422
|
+
return await self._validator.validate_action(action, service_detail, allow_wildcards)
|
|
423
|
+
|
|
424
|
+
def validate_arn(self, arn: str) -> tuple[bool, str | None]:
|
|
425
|
+
"""Validate ARN format.
|
|
426
|
+
|
|
427
|
+
Args:
|
|
428
|
+
arn: ARN string to validate
|
|
429
|
+
|
|
430
|
+
Returns:
|
|
431
|
+
Tuple of (is_valid, error_message)
|
|
432
|
+
|
|
433
|
+
Example:
|
|
434
|
+
>>> fetcher = AWSServiceFetcher()
|
|
435
|
+
>>> is_valid, error = fetcher.validate_arn("arn:aws:s3:::my-bucket/*")
|
|
436
|
+
>>> if not is_valid:
|
|
437
|
+
... print(f"Invalid ARN: {error}")
|
|
438
|
+
"""
|
|
439
|
+
return self._parser.validate_arn_format(arn)
|
|
440
|
+
|
|
441
|
+
async def validate_condition_key(
|
|
442
|
+
self,
|
|
443
|
+
action: str,
|
|
444
|
+
condition_key: str,
|
|
445
|
+
resources: list[str] | None = None,
|
|
446
|
+
) -> ConditionKeyValidationResult:
|
|
447
|
+
"""Validate condition key.
|
|
448
|
+
|
|
449
|
+
Args:
|
|
450
|
+
action: IAM action (e.g., "s3:GetObject")
|
|
451
|
+
condition_key: Condition key to validate (e.g., "s3:prefix")
|
|
452
|
+
resources: Optional list of resource ARNs
|
|
453
|
+
|
|
454
|
+
Returns:
|
|
455
|
+
ConditionKeyValidationResult with validation details
|
|
456
|
+
|
|
457
|
+
Example:
|
|
458
|
+
>>> async with AWSServiceFetcher() as fetcher:
|
|
459
|
+
... result = await fetcher.validate_condition_key("s3:GetObject", "s3:prefix")
|
|
460
|
+
... if not result.is_valid:
|
|
461
|
+
... print(f"Invalid condition key: {result.error_message}")
|
|
462
|
+
"""
|
|
463
|
+
service_prefix, _ = self._parser.parse_action(action)
|
|
464
|
+
service_detail = await self.fetch_service_by_name(service_prefix)
|
|
465
|
+
return await self._validator.validate_condition_key(
|
|
466
|
+
action, condition_key, service_detail, resources
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
# --- Parsing Methods (delegate to parser) ---
|
|
470
|
+
|
|
471
|
+
def parse_action(self, action: str) -> tuple[str, str]:
|
|
472
|
+
"""Parse action into service and action name.
|
|
473
|
+
|
|
474
|
+
Args:
|
|
475
|
+
action: Full action string (e.g., "s3:GetObject")
|
|
476
|
+
|
|
477
|
+
Returns:
|
|
478
|
+
Tuple of (service_prefix, action_name)
|
|
479
|
+
|
|
480
|
+
Example:
|
|
481
|
+
>>> fetcher = AWSServiceFetcher()
|
|
482
|
+
>>> service, action_name = fetcher.parse_action("s3:GetObject")
|
|
483
|
+
>>> print(f"Service: {service}, Action: {action_name}")
|
|
484
|
+
"""
|
|
485
|
+
return self._parser.parse_action(action)
|
|
486
|
+
|
|
487
|
+
def match_wildcard_action(
|
|
488
|
+
self,
|
|
489
|
+
pattern: str,
|
|
490
|
+
actions: list[str],
|
|
491
|
+
) -> tuple[bool, list[str]]:
|
|
492
|
+
"""Match wildcard pattern against actions.
|
|
493
|
+
|
|
494
|
+
Args:
|
|
495
|
+
pattern: Action pattern with wildcards (e.g., "Get*", "*Object")
|
|
496
|
+
actions: List of action names to match against
|
|
497
|
+
|
|
498
|
+
Returns:
|
|
499
|
+
Tuple of (has_matches, list_of_matched_actions)
|
|
500
|
+
|
|
501
|
+
Example:
|
|
502
|
+
>>> fetcher = AWSServiceFetcher()
|
|
503
|
+
>>> actions = ["GetObject", "PutObject", "DeleteObject"]
|
|
504
|
+
>>> has_matches, matched = fetcher.match_wildcard_action("Get*", actions)
|
|
505
|
+
>>> print(f"Matched: {matched}")
|
|
506
|
+
"""
|
|
507
|
+
return self._parser.match_wildcard_action(pattern, actions)
|
|
508
|
+
|
|
509
|
+
# --- Helper Methods ---
|
|
510
|
+
|
|
511
|
+
async def get_resources_for_action(self, action: str) -> list[dict[str, Any]]:
|
|
512
|
+
"""Get resource types for action.
|
|
513
|
+
|
|
514
|
+
Args:
|
|
515
|
+
action: Full action name (e.g., "s3:GetObject")
|
|
516
|
+
|
|
517
|
+
Returns:
|
|
518
|
+
List of resource dictionaries from AWS API
|
|
519
|
+
|
|
520
|
+
Example:
|
|
521
|
+
>>> async with AWSServiceFetcher() as fetcher:
|
|
522
|
+
... resources = await fetcher.get_resources_for_action("s3:GetObject")
|
|
523
|
+
... print(f"Action operates on {len(resources)} resource types")
|
|
524
|
+
"""
|
|
525
|
+
service_prefix, _ = self._parser.parse_action(action)
|
|
526
|
+
service_detail = await self.fetch_service_by_name(service_prefix)
|
|
527
|
+
return self._validator.get_resources_for_action(action, service_detail)
|
|
528
|
+
|
|
529
|
+
async def get_arn_formats_for_action(self, action: str) -> list[str]:
|
|
530
|
+
"""Get ARN formats for action.
|
|
531
|
+
|
|
532
|
+
Args:
|
|
533
|
+
action: Full action name (e.g., "s3:GetObject")
|
|
534
|
+
|
|
535
|
+
Returns:
|
|
536
|
+
List of ARN format strings
|
|
537
|
+
|
|
538
|
+
Example:
|
|
539
|
+
>>> async with AWSServiceFetcher() as fetcher:
|
|
540
|
+
... arns = await fetcher.get_arn_formats_for_action("s3:GetObject")
|
|
541
|
+
... for arn in arns:
|
|
542
|
+
... print(f"ARN format: {arn}")
|
|
543
|
+
"""
|
|
544
|
+
service_prefix, _ = self._parser.parse_action(action)
|
|
545
|
+
service_detail = await self.fetch_service_by_name(service_prefix)
|
|
546
|
+
return self._validator.get_arn_formats_for_action(action, service_detail)
|
|
547
|
+
|
|
548
|
+
async def get_all_actions_for_service(self, service: str) -> list[str]:
|
|
549
|
+
"""Get all actions for service.
|
|
550
|
+
|
|
551
|
+
Args:
|
|
552
|
+
service: Service prefix (e.g., "s3", "iam", "ec2")
|
|
553
|
+
|
|
554
|
+
Returns:
|
|
555
|
+
Sorted list of action names (without service prefix)
|
|
556
|
+
|
|
557
|
+
Example:
|
|
558
|
+
>>> async with AWSServiceFetcher() as fetcher:
|
|
559
|
+
... actions = await fetcher.get_all_actions_for_service("s3")
|
|
560
|
+
... print(f"S3 has {len(actions)} actions")
|
|
561
|
+
"""
|
|
562
|
+
service_detail = await self.fetch_service_by_name(service)
|
|
563
|
+
return sorted(service_detail.actions.keys())
|
|
564
|
+
|
|
565
|
+
async def expand_wildcard_action(self, action_pattern: str) -> list[str]:
|
|
566
|
+
"""Expand wildcard action to full list.
|
|
567
|
+
|
|
568
|
+
Args:
|
|
569
|
+
action_pattern: Action with wildcards (e.g., "iam:Create*", "s3:*Object")
|
|
570
|
+
|
|
571
|
+
Returns:
|
|
572
|
+
Sorted list of fully-qualified actions matching the pattern
|
|
573
|
+
|
|
574
|
+
Example:
|
|
575
|
+
>>> async with AWSServiceFetcher() as fetcher:
|
|
576
|
+
... actions = await fetcher.expand_wildcard_action("iam:Create*")
|
|
577
|
+
... print(f"Pattern matches {len(actions)} actions")
|
|
578
|
+
"""
|
|
579
|
+
if action_pattern in ("*", "*:*"):
|
|
580
|
+
return ["*"]
|
|
581
|
+
|
|
582
|
+
service_prefix, _ = self._parser.parse_action(action_pattern)
|
|
583
|
+
service_detail = await self.fetch_service_by_name(service_prefix)
|
|
584
|
+
available = list(service_detail.actions.keys())
|
|
585
|
+
return self._parser.expand_wildcard_to_actions(action_pattern, available, service_prefix)
|
|
586
|
+
|
|
587
|
+
# --- Cache Management ---
|
|
588
|
+
|
|
589
|
+
async def clear_caches(self) -> None:
|
|
590
|
+
"""Clear all caches (memory and disk).
|
|
591
|
+
|
|
592
|
+
Example:
|
|
593
|
+
>>> async with AWSServiceFetcher() as fetcher:
|
|
594
|
+
... await fetcher.clear_caches()
|
|
595
|
+
"""
|
|
596
|
+
await self._cache.clear()
|
|
597
|
+
|
|
598
|
+
def set_cache_directory(self, cache_dir: Path | str) -> None:
|
|
599
|
+
"""Set a new cache directory path dynamically.
|
|
600
|
+
|
|
601
|
+
This method allows library users to change the cache location at runtime.
|
|
602
|
+
Useful for applications that need to control where cache files are stored.
|
|
603
|
+
|
|
604
|
+
Args:
|
|
605
|
+
cache_dir: New cache directory path
|
|
606
|
+
|
|
607
|
+
Example:
|
|
608
|
+
>>> async with AWSServiceFetcher() as fetcher:
|
|
609
|
+
... fetcher.set_cache_directory("/tmp/my-custom-cache")
|
|
610
|
+
... # Future cache operations will use the new directory
|
|
611
|
+
"""
|
|
612
|
+
self._storage.set_cache_directory(cache_dir)
|
|
613
|
+
|
|
614
|
+
def get_cache_directory(self) -> Path:
|
|
615
|
+
"""Get the current cache directory path.
|
|
616
|
+
|
|
617
|
+
Returns:
|
|
618
|
+
Current cache directory as Path object
|
|
619
|
+
|
|
620
|
+
Example:
|
|
621
|
+
>>> async with AWSServiceFetcher() as fetcher:
|
|
622
|
+
... cache_path = fetcher.get_cache_directory()
|
|
623
|
+
... print(f"Cache location: {cache_path}")
|
|
624
|
+
"""
|
|
625
|
+
return self._storage.cache_directory
|
|
626
|
+
|
|
627
|
+
def get_stats(self) -> dict[str, Any]:
|
|
628
|
+
"""Get fetcher statistics for monitoring.
|
|
629
|
+
|
|
630
|
+
Returns:
|
|
631
|
+
Dictionary with cache and prefetch statistics
|
|
632
|
+
|
|
633
|
+
Example:
|
|
634
|
+
>>> async with AWSServiceFetcher() as fetcher:
|
|
635
|
+
... stats = fetcher.get_stats()
|
|
636
|
+
... print(f"Prefetched {stats['prefetched_services']} services")
|
|
637
|
+
"""
|
|
638
|
+
return {
|
|
639
|
+
"prefetched_services": len(self._prefetched_services),
|
|
640
|
+
**self._cache.get_stats(),
|
|
641
|
+
}
|