awslabs.postgres-mcp-server 1.0.9__py3-none-any.whl → 1.0.11__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.
@@ -14,4 +14,11 @@
14
14
 
15
15
  """awslabs.postgres-mcp-server"""
16
16
 
17
- __version__ = '1.0.9'
17
+ from importlib.metadata import version
18
+
19
+ try:
20
+ __version__ = version('awslabs.postgres-mcp-server')
21
+ except Exception:
22
+ __version__ = '1.0.11'
23
+
24
+ __user_agent__ = f'awslabs/mcp/postgres_mcp_server/{__version__}'
@@ -14,5 +14,4 @@
14
14
 
15
15
  """aws.postgres-mcp-server.connection"""
16
16
 
17
- from awslabs.postgres_mcp_server.connection.db_connection_singleton import DBConnectionSingleton
18
17
  from awslabs.postgres_mcp_server.connection.abstract_db_connection import AbstractDBConnection
@@ -0,0 +1,592 @@
1
+ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import boto3
16
+ import json
17
+ import time
18
+ import traceback
19
+ from awslabs.postgres_mcp_server import __user_agent__
20
+ from botocore.config import Config
21
+ from botocore.exceptions import ClientError
22
+ from loguru import logger
23
+ from typing import Any, Dict, Optional
24
+
25
+
26
+ def internal_create_rds_client(region: str):
27
+ """Create an RDS client with custom user agent configuration."""
28
+ return boto3.client('rds', region_name=region, config=Config(user_agent_extra=__user_agent__))
29
+
30
+
31
+ def internal_get_instance_properties(target_endpoint: str, region: str) -> Dict[str, Any]:
32
+ """Retrieve RDS instance properties from AWS."""
33
+ rds_client = internal_create_rds_client(region=region)
34
+ paginator = rds_client.get_paginator('describe_db_instances')
35
+
36
+ # Iterate through all instances
37
+ try:
38
+ for page in paginator.paginate():
39
+ for instance in page['DBInstances']:
40
+ endpoint = instance.get('Endpoint', {}).get('Address')
41
+ if endpoint == target_endpoint:
42
+ return instance
43
+ except ClientError as e:
44
+ error_code = e.response['Error']['Code']
45
+ logger.error(
46
+ f'AWS error fetching all instances in region:{region} '
47
+ f'{error_code} - {e.response["Error"]["Message"]}'
48
+ )
49
+ raise
50
+ except Exception as e:
51
+ logger.error(
52
+ f'Error fetchingall instances in region:{region}. Error: {type(e).__name__}: {e}'
53
+ )
54
+ raise
55
+
56
+ not_found_error = (
57
+ f"AWS error fetching instance by endpoint: '{target_endpoint}' in region:{region}"
58
+ )
59
+ logger.error(not_found_error)
60
+ raise ValueError(not_found_error)
61
+
62
+
63
+ def internal_get_cluster_properties(cluster_identifier: str, region: str) -> Dict[str, Any]:
64
+ """Retrieve RDS cluster properties from AWS.
65
+
66
+ Args:
67
+ cluster_identifier: RDS cluster identifier
68
+ region: AWS region (e.g., 'us-east-1')
69
+
70
+ Returns:
71
+ Dict[str, Any]: Cluster properties from AWS RDS API
72
+
73
+ Raises:
74
+ ValueError: If cluster_identifier or region is empty
75
+ ClientError: If AWS API call fails (cluster not found, access denied, etc.)
76
+ NoCredentialsError: If AWS credentials not configured
77
+
78
+ Example:
79
+ >>> props = internal_get_cluster_properties('my-cluster', 'us-east-1')
80
+ >>> print(props['Status'])
81
+ """
82
+ # Input validation
83
+ if not cluster_identifier or not region:
84
+ raise ValueError('cluster_identifier and region are required')
85
+
86
+ logger.info(f"Fetching properties for cluster '{cluster_identifier}' in '{region}' ")
87
+
88
+ try:
89
+ rds_client = internal_create_rds_client(region)
90
+ response = rds_client.describe_db_clusters(DBClusterIdentifier=cluster_identifier)
91
+
92
+ # Safely extract cluster properties
93
+ clusters = response.get('DBClusters', [])
94
+ if not clusters:
95
+ raise ValueError(f"Cluster '{cluster_identifier}' not found in region '{region}'")
96
+
97
+ cluster_properties = clusters[0]
98
+
99
+ # Log summary only
100
+ logger.info(
101
+ f"Retrieved cluster '{cluster_identifier}': "
102
+ f'Status={cluster_properties.get("Status")}, '
103
+ f'Engine={cluster_properties.get("Engine")}'
104
+ )
105
+
106
+ # Full properties at debug level
107
+ logger.debug(
108
+ f'Cluster properties: {json.dumps(cluster_properties, indent=2, default=str)}'
109
+ )
110
+
111
+ return cluster_properties
112
+
113
+ except ClientError as e:
114
+ error_code = e.response['Error']['Code']
115
+ logger.error(
116
+ f"AWS error fetching cluster '{cluster_identifier}': "
117
+ f'{error_code} - {e.response["Error"]["Message"]}'
118
+ )
119
+ raise
120
+ except Exception as e:
121
+ logger.error(f'Error fetching cluster properties: {type(e).__name__}: {e}')
122
+ raise
123
+
124
+
125
+ def internal_create_serverless_cluster(
126
+ region: str,
127
+ cluster_identifier: str,
128
+ engine_version: str,
129
+ database_name: str,
130
+ master_username: str = 'postgres',
131
+ min_capacity: float = 0.5,
132
+ max_capacity: float = 4,
133
+ enable_cloudwatch_logs: bool = True,
134
+ ) -> Dict[str, Any]:
135
+ """Create an Aurora PostgreSQL cluster with a single writer instance.
136
+
137
+ Credentials are automatically managed by AWS Secrets Manager.
138
+
139
+ Args:
140
+ region: region of the cluster
141
+ cluster_identifier: Name of the Aurora cluster
142
+ engine_version: PostgreSQL engine version (e.g., '15.3', '14.7')
143
+ database_name: Name of the default database
144
+ master_username: Master username for the database
145
+ min_capacity: minimum ACU capacity
146
+ max_capacity: maximum ACU capacity
147
+ enable_cloudwatch_logs: Enable CloudWatch logs export
148
+
149
+ Returns:
150
+ Dictionary containing cluster information and secret ARN
151
+ """
152
+ if not region:
153
+ raise ValueError('region is required')
154
+ if not cluster_identifier:
155
+ raise ValueError('cluster_identifier is required')
156
+ if not engine_version:
157
+ raise ValueError('engine_version is required')
158
+ if not database_name:
159
+ raise ValueError('database_name is required')
160
+
161
+ rds_client = internal_create_rds_client(region=region)
162
+
163
+ # Add default tags
164
+ tags = []
165
+ tags.append({'Key': 'CreatedBy', 'Value': 'MCP'})
166
+
167
+ # Prepare CloudWatch logs
168
+ enable_cloudwatch_logs_exports = []
169
+ if enable_cloudwatch_logs:
170
+ enable_cloudwatch_logs_exports = ['postgresql']
171
+
172
+ try:
173
+ # Create the Aurora cluster
174
+ logger.info(
175
+ f'Creating Aurora PostgreSQL cluster:{cluster_identifier} '
176
+ f'region:{region} engine_version:{engine_version} database_name:{database_name} '
177
+ f'master_username:{master_username}'
178
+ )
179
+
180
+ cluster_params = {
181
+ 'DBClusterIdentifier': cluster_identifier,
182
+ 'Engine': 'aurora-postgresql',
183
+ 'EngineVersion': engine_version,
184
+ 'MasterUsername': master_username,
185
+ 'DatabaseName': database_name,
186
+ 'ManageMasterUserPassword': True, # Enable Secrets Manager integration
187
+ 'Tags': tags,
188
+ 'DeletionProtection': False, # Set to True for production
189
+ 'CopyTagsToSnapshot': True,
190
+ 'EnableHttpEndpoint': True, # Enable for Data API if needed
191
+ 'EnableCloudwatchLogsExports': enable_cloudwatch_logs_exports,
192
+ }
193
+
194
+ cluster_params['ServerlessV2ScalingConfiguration'] = {
195
+ 'MinCapacity': min_capacity,
196
+ 'MaxCapacity': max_capacity,
197
+ }
198
+
199
+ # Create the cluster
200
+ cluster_create_start_time = time.time()
201
+ cluster_response = rds_client.create_db_cluster(**cluster_params)
202
+
203
+ cluster_info = cluster_response['DBCluster']
204
+ logger.info(
205
+ f'Cluster {cluster_identifier} creation call started successfully. Status: {cluster_info["Status"]}'
206
+ )
207
+
208
+ # Wait for cluster to be available
209
+ logger.info('Waiting for cluster to become available...')
210
+ waiter = rds_client.get_waiter('db_cluster_available')
211
+ waiter.wait(
212
+ DBClusterIdentifier=cluster_identifier, WaiterConfig={'Delay': 5, 'MaxAttempts': 120}
213
+ )
214
+
215
+ logger.info(f'Cluster {cluster_identifier} is now available')
216
+ cluster_create_stop_time = time.time()
217
+ elapsed_time = cluster_create_stop_time - cluster_create_start_time
218
+ logger.info(f'Cluster creation {cluster_identifier} took {elapsed_time:.2f} seconds')
219
+
220
+ # Create the writer instance
221
+ instance_identifier = f'{cluster_identifier}-instance-1'
222
+ logger.info(f'Creating writer instance: {instance_identifier}')
223
+
224
+ instance_params = {
225
+ 'DBInstanceIdentifier': instance_identifier,
226
+ 'DBInstanceClass': 'db.serverless',
227
+ 'Engine': 'aurora-postgresql',
228
+ 'DBClusterIdentifier': cluster_identifier,
229
+ 'PubliclyAccessible': False, # Set to True if needed
230
+ 'Tags': tags,
231
+ 'CopyTagsToSnapshot': True,
232
+ }
233
+
234
+ instance_create_start_time = time.time()
235
+ rds_client.create_db_instance(**instance_params)
236
+
237
+ logger.info(f'Writer instance {instance_identifier} created successfully')
238
+
239
+ # Wait for instance to be available
240
+ logger.info(f'Waiting for instance {instance_identifier} to become available...')
241
+ instance_waiter = rds_client.get_waiter('db_instance_available')
242
+ instance_waiter.wait(
243
+ DBInstanceIdentifier=instance_identifier,
244
+ WaiterConfig={
245
+ 'Delay': 1, # check every seconds
246
+ 'MaxAttempts': 1800, # Try up to 1800 time = 30 mins
247
+ },
248
+ )
249
+
250
+ logger.info(f'Instance {instance_identifier} is now available')
251
+ instance_create_stop_time = time.time()
252
+ elapsed_time = instance_create_stop_time - instance_create_start_time
253
+ logger.info(f'Instance creation {instance_identifier} took {elapsed_time:.2f} seconds')
254
+
255
+ # Get the final cluster details including the secret ARN
256
+ final_cluster = rds_client.describe_db_clusters(DBClusterIdentifier=cluster_identifier)[
257
+ 'DBClusters'
258
+ ][0]
259
+
260
+ return final_cluster
261
+
262
+ except ClientError as e:
263
+ logger.error(
264
+ f"AWS error creating serverless cluster '{cluster_identifier}': "
265
+ f'{e.response["Error"]["Code"]} - {e.response["Error"]["Message"]}'
266
+ )
267
+ raise
268
+ except Exception as e:
269
+ logger.error(
270
+ f"Error creating serverless cluster '{cluster_identifier}': {type(e).__name__}: {e}"
271
+ )
272
+ raise
273
+
274
+
275
+ def setup_aurora_iam_policy_for_current_user(
276
+ db_user: str, cluster_resource_id: str, cluster_region: str
277
+ ) -> Optional[str]:
278
+ """Create or update IAM policy for Aurora access.
279
+
280
+ Maintains one policy per user, adding new clusters as they're created.
281
+
282
+ ⚠️ If running as assumed role, this will attempt to attach the policy to
283
+ the BASE ROLE (not the session). This requires iam:AttachRolePolicy permission.
284
+
285
+ Args:
286
+ db_user: PostgreSQL username (must have rds_iam role granted in database)
287
+ cluster_resource_id: The DBI resource ID (e.g., 'cluster-ABCD123XYZ')
288
+ cluster_region: AWS region where the Aurora cluster is located
289
+
290
+ Returns:
291
+ Policy ARN if successful, None otherwise
292
+
293
+ Raises:
294
+ ValueError: If running as federated user, root, or invalid identity
295
+ boto3 exceptions: For AWS API errors (except AccessDenied on attach)
296
+ """
297
+ # Validate inputs
298
+ if not db_user or not isinstance(db_user, str):
299
+ raise ValueError('db_user must be a non-empty string')
300
+ if not cluster_resource_id or not isinstance(cluster_resource_id, str):
301
+ raise ValueError('cluster_resource_id must be a non-empty string')
302
+ if not cluster_region or not isinstance(cluster_region, str):
303
+ raise ValueError('cluster_region must be a non-empty string')
304
+
305
+ # Initialize clients
306
+ sts = boto3.client('sts', config=Config(user_agent_extra=__user_agent__))
307
+ iam = boto3.client('iam', config=Config(user_agent_extra=__user_agent__))
308
+
309
+ # 1. Get current IAM identity
310
+ try:
311
+ identity = sts.get_caller_identity()
312
+ account_id = identity['Account']
313
+ arn = identity['Arn']
314
+ user_id = identity['UserId']
315
+
316
+ logger.info('Current Identity:')
317
+ logger.info(f' ARN: {arn}')
318
+ logger.info(f' Account: {account_id}')
319
+ logger.info(f' UserID: {user_id}')
320
+
321
+ except Exception as e:
322
+ logger.error(f'❌ Error getting caller identity: {e}')
323
+ raise
324
+
325
+ # ============================================================================
326
+ # 🔵 MODIFIED: Extract base role from assumed role session
327
+ # ============================================================================
328
+ # 2. Extract username/role from ARN and determine identity type
329
+ current_user = None
330
+ current_role = None
331
+ identity_type = None
332
+
333
+ if ':user/' in arn:
334
+ # Standard IAM user: arn:aws:iam::123456789012:user/username
335
+ current_user = arn.split(':user/')[-1].split('/')[-1]
336
+ identity_type = 'user'
337
+ logger.info(' Type: IAM User')
338
+ logger.info(f' Username: {current_user}')
339
+
340
+ elif ':assumed-role/' in arn:
341
+ # 🔵 MODIFIED: Extract BASE ROLE name from assumed role session
342
+ # Assumed role ARN: arn:aws:sts::123456789012:assumed-role/RoleName/session-name
343
+ # We want to extract "RoleName" (the base role)
344
+ parts = arn.split(':assumed-role/')[-1].split('/')
345
+ current_role = parts[0] # This is the BASE ROLE name
346
+ session_name = parts[1] if len(parts) > 1 else 'unknown'
347
+
348
+ identity_type = 'role'
349
+ logger.info(' Type: Assumed Role Session')
350
+ logger.info(f' Base Role: {current_role}')
351
+ logger.info(f' Session Name: {session_name}')
352
+ logger.info(f' → Will attach policy to base role: {current_role}')
353
+ logger.warning(
354
+ f"⚠️ Policy will be attached to role '{current_role}'\n"
355
+ f' This will grant Aurora access to ALL users/services that assume this role.'
356
+ )
357
+
358
+ elif ':federated-user/' in arn:
359
+ logger.error(' Type: Federated User')
360
+ raise ValueError(
361
+ 'Cannot attach policies to federated users.\n'
362
+ 'Please use the parent IAM user or role instead.'
363
+ )
364
+
365
+ elif ':root' in arn:
366
+ logger.error(' Type: Root User')
367
+ raise ValueError(
368
+ 'Cannot (and should not) attach policies to root user.\n'
369
+ 'Please use an IAM user instead.'
370
+ )
371
+
372
+ else:
373
+ raise ValueError(f'Unexpected ARN format: {arn}')
374
+
375
+ # 3. Prepare new resource ARN
376
+ policy_name = f'AuroraIAMAuth-{db_user}'
377
+ policy_arn = f'arn:aws:iam::{account_id}:policy/{policy_name}'
378
+
379
+ new_resource_arn = (
380
+ f'arn:aws:rds-db:{cluster_region}:{account_id}:dbuser:{cluster_resource_id}/{db_user}'
381
+ )
382
+
383
+ logger.info('\nPolicy Configuration:')
384
+ logger.info(f' Policy Name: {policy_name}')
385
+ logger.info(f' New Resource: {new_resource_arn}')
386
+ logger.info(f' Cluster Region: {cluster_region}')
387
+ logger.info(f' Cluster Resource ID: {cluster_resource_id}')
388
+
389
+ # 4. Create or update policy
390
+
391
+ try:
392
+ # Try to get existing policy
393
+ existing_policy = iam.get_policy(PolicyArn=policy_arn)
394
+ logger.info(f'\n✓ Policy already exists: {policy_name}')
395
+
396
+ # Get current policy document
397
+ policy_version = iam.get_policy_version(
398
+ PolicyArn=policy_arn, VersionId=existing_policy['Policy']['DefaultVersionId']
399
+ )
400
+
401
+ current_doc = policy_version['PolicyVersion']['Document']
402
+ current_resources = current_doc['Statement'][0]['Resource']
403
+
404
+ # Normalize to list (could be string or list)
405
+ if isinstance(current_resources, str):
406
+ current_resources = [current_resources]
407
+
408
+ logger.info(f' Current resources in policy: {len(current_resources)}')
409
+ for idx, res in enumerate(current_resources, 1):
410
+ logger.info(f' {idx}. {res}')
411
+
412
+ # Check if new resource already exists
413
+ if new_resource_arn in current_resources:
414
+ logger.info('\n✓ Cluster already included in policy - no update needed')
415
+ else:
416
+ # Add new resource to the list
417
+ current_resources.append(new_resource_arn)
418
+ logger.info('\n→ Adding new cluster to policy...')
419
+
420
+ # Create updated policy document
421
+ updated_doc = {
422
+ 'Version': '2012-10-17',
423
+ 'Statement': [
424
+ {'Effect': 'Allow', 'Action': 'rds-db:connect', 'Resource': current_resources}
425
+ ],
426
+ }
427
+
428
+ # Handle AWS policy version limits (max 5 versions per policy)
429
+ versions = iam.list_policy_versions(PolicyArn=policy_arn)['Versions']
430
+ logger.info(f' Current policy versions: {len(versions)}/5')
431
+
432
+ if len(versions) >= 5:
433
+ # Find oldest non-default version to delete
434
+ non_default_versions = [v for v in versions if not v['IsDefaultVersion']]
435
+ if non_default_versions:
436
+ oldest_version = sorted(non_default_versions, key=lambda v: v['CreateDate'])[0]
437
+ logger.info(
438
+ f' Deleting oldest version: {oldest_version["VersionId"]} (created {oldest_version["CreateDate"]})'
439
+ )
440
+ iam.delete_policy_version(
441
+ PolicyArn=policy_arn, VersionId=oldest_version['VersionId']
442
+ )
443
+
444
+ # Create new policy version
445
+ new_version = iam.create_policy_version(
446
+ PolicyArn=policy_arn,
447
+ PolicyDocument=json.dumps(updated_doc, indent=2),
448
+ SetAsDefault=True,
449
+ )
450
+
451
+ logger.info('✓ Successfully updated policy')
452
+ logger.info(f' New version: {new_version["PolicyVersion"]["VersionId"]}')
453
+ logger.info(f' Total resources now: {len(current_resources)}')
454
+
455
+ except iam.exceptions.NoSuchEntityException:
456
+ # Policy doesn't exist - create new one
457
+ logger.info("\nPolicy doesn't exist, creating new policy...")
458
+
459
+ policy_document = {
460
+ 'Version': '2012-10-17',
461
+ 'Statement': [
462
+ {'Effect': 'Allow', 'Action': 'rds-db:connect', 'Resource': [new_resource_arn]}
463
+ ],
464
+ }
465
+
466
+ try:
467
+ policy_response = iam.create_policy(
468
+ PolicyName=policy_name,
469
+ PolicyDocument=json.dumps(policy_document, indent=2),
470
+ Description=f'IAM authentication for Aurora PostgreSQL user {db_user} across all clusters',
471
+ )
472
+ policy_arn = policy_response['Policy']['Arn']
473
+ logger.info(f'✓ Successfully created new policy: {policy_name}')
474
+ logger.info(f' Policy ARN: {policy_arn}')
475
+
476
+ except iam.exceptions.EntityAlreadyExistsException:
477
+ logger.info('✓ Policy was just created by another process')
478
+
479
+ except Exception as e:
480
+ logger.error(f'\n❌ Error creating policy: {e}')
481
+ raise
482
+
483
+ except Exception as e:
484
+ logger.error(f'\n❌ Error checking/updating policy: {e}')
485
+ trace_msg = traceback.format_exc()
486
+ logger.error(f'Traceback: {trace_msg}')
487
+ raise
488
+
489
+ # ============================================================================
490
+ # 🔵 MODIFIED: Attach to base role with better error handling
491
+ # ============================================================================
492
+ # 5. Attach policy to current user OR base role
493
+ try:
494
+ if identity_type == 'user':
495
+ # IAM User - attach directly
496
+ attached_policies = iam.list_attached_user_policies(UserName=current_user)
497
+ already_attached = any(
498
+ p['PolicyArn'] == policy_arn for p in attached_policies['AttachedPolicies']
499
+ )
500
+
501
+ if already_attached:
502
+ logger.info(f'\n✓ Policy already attached to user: {current_user}')
503
+ else:
504
+ iam.attach_user_policy(UserName=current_user, PolicyArn=policy_arn)
505
+ logger.info(f'\n✓ Successfully attached policy to user: {current_user}')
506
+
507
+ # Display summary
508
+ logger.info(f'\nAttached policies for user {current_user}:')
509
+ attached_policies = iam.list_attached_user_policies(UserName=current_user)
510
+ for policy in attached_policies['AttachedPolicies']:
511
+ marker = ' → ' if policy['PolicyArn'] == policy_arn else ' '
512
+ logger.info(f'{marker}{policy["PolicyName"]}')
513
+
514
+ elif identity_type == 'role':
515
+ # 🔵 MODIFIED: Attach to BASE ROLE (not session)
516
+ logger.info(f'\n→ Attempting to attach policy to base role: {current_role}')
517
+
518
+ try:
519
+ # Check if already attached to the base role
520
+ attached_policies = iam.list_attached_role_policies(RoleName=current_role)
521
+ already_attached = any(
522
+ p['PolicyArn'] == policy_arn for p in attached_policies['AttachedPolicies']
523
+ )
524
+
525
+ if already_attached:
526
+ logger.info(f'\n✓ Policy already attached to role: {current_role}')
527
+ else:
528
+ # Attach to the BASE ROLE
529
+ iam.attach_role_policy(RoleName=current_role, PolicyArn=policy_arn)
530
+ logger.info(f'\n✓ Successfully attached policy to role: {current_role}')
531
+ logger.warning(
532
+ f"⚠️ All users/services assuming role '{current_role}' now have Aurora access"
533
+ )
534
+
535
+ # Display summary
536
+ logger.info(f'\nAttached policies for role {current_role}:')
537
+ attached_policies = iam.list_attached_role_policies(RoleName=current_role)
538
+ for policy in attached_policies['AttachedPolicies']:
539
+ marker = ' → ' if policy['PolicyArn'] == policy_arn else ' '
540
+ logger.info(f'{marker}{policy["PolicyName"]}')
541
+
542
+ except iam.exceptions.AccessDeniedException:
543
+ # 🔵 MODIFIED: Graceful handling of permission denied
544
+ logger.error(f"\n❌ Access Denied: Cannot attach policy to role '{current_role}'")
545
+ logger.error(" Your session does not have 'iam:AttachRolePolicy' permission")
546
+ logger.info(f'\n✓ Policy created successfully: {policy_arn}')
547
+ logger.info(' But could not be attached automatically.')
548
+ logger.info('\n📋 MANUAL STEPS REQUIRED:')
549
+ logger.info('\n Option 1: Have an administrator attach the policy to the role')
550
+ logger.info(' aws iam attach-role-policy \\')
551
+ logger.info(f' --role-name {current_role} \\')
552
+ logger.info(f' --policy-arn {policy_arn}')
553
+ logger.info('\n Option 2: Attach to your individual IAM user (if you have one)')
554
+ logger.info(' aws iam attach-user-policy \\')
555
+ logger.info(' --user-name YOUR_IAM_USERNAME \\')
556
+ logger.info(f' --policy-arn {policy_arn}')
557
+ logger.info('\n Option 3: Grant the role permission to attach policies')
558
+ logger.info(
559
+ f" (Admin needs to add iam:AttachRolePolicy to role '{current_role}')"
560
+ )
561
+
562
+ # Return policy ARN even though not attached
563
+ return policy_arn
564
+
565
+ except iam.exceptions.NoSuchEntityException:
566
+ logger.error(f"\n❌ Role '{current_role}' not found")
567
+ logger.error(" This is unexpected - the role should exist since you're using it")
568
+ raise
569
+
570
+ return policy_arn
571
+
572
+ except iam.exceptions.NoSuchEntityException:
573
+ entity_name = current_user if identity_type == 'user' else current_role
574
+ entity_type = 'User' if identity_type == 'user' else 'Role'
575
+ logger.error(f"\n❌ Error: {entity_type} '{entity_name}' not found")
576
+ raise
577
+
578
+ except iam.exceptions.LimitExceededException:
579
+ entity_name = current_user if identity_type == 'user' else current_role
580
+ entity_type = 'user' if identity_type == 'user' else 'role'
581
+ logger.error(
582
+ f"\n❌ Error: Managed policy limit exceeded for {entity_type} '{entity_name}'"
583
+ )
584
+ logger.error('Maximum 10 managed policies can be attached to a user or role')
585
+ logger.error('Consider using inline policies or consolidating existing policies')
586
+ raise
587
+
588
+ except Exception as e:
589
+ logger.error(f'\n❌ Error attaching policy: {e}')
590
+ trace_msg = traceback.format_exc()
591
+ logger.error(f'Traceback: {trace_msg}')
592
+ raise