awslabs.postgres-mcp-server 1.0.9__py3-none-any.whl → 1.0.12__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.
- awslabs/postgres_mcp_server/__init__.py +8 -1
- awslabs/postgres_mcp_server/connection/__init__.py +0 -1
- awslabs/postgres_mcp_server/connection/cp_api_connection.py +592 -0
- awslabs/postgres_mcp_server/connection/db_connection_map.py +128 -0
- awslabs/postgres_mcp_server/connection/psycopg_pool_connection.py +101 -54
- awslabs/postgres_mcp_server/connection/rds_api_connection.py +5 -1
- awslabs/postgres_mcp_server/server.py +562 -120
- {awslabs_postgres_mcp_server-1.0.9.dist-info → awslabs_postgres_mcp_server-1.0.12.dist-info}/METADATA +50 -81
- awslabs_postgres_mcp_server-1.0.12.dist-info/RECORD +16 -0
- {awslabs_postgres_mcp_server-1.0.9.dist-info → awslabs_postgres_mcp_server-1.0.12.dist-info}/WHEEL +1 -1
- awslabs/postgres_mcp_server/connection/db_connection_singleton.py +0 -117
- awslabs_postgres_mcp_server-1.0.9.dist-info/RECORD +0 -15
- {awslabs_postgres_mcp_server-1.0.9.dist-info → awslabs_postgres_mcp_server-1.0.12.dist-info}/entry_points.txt +0 -0
- {awslabs_postgres_mcp_server-1.0.9.dist-info → awslabs_postgres_mcp_server-1.0.12.dist-info}/licenses/LICENSE +0 -0
- {awslabs_postgres_mcp_server-1.0.9.dist-info → awslabs_postgres_mcp_server-1.0.12.dist-info}/licenses/NOTICE +0 -0
|
@@ -14,4 +14,11 @@
|
|
|
14
14
|
|
|
15
15
|
"""awslabs.postgres-mcp-server"""
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
from importlib.metadata import version
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
__version__ = version('awslabs.postgres-mcp-server')
|
|
21
|
+
except Exception:
|
|
22
|
+
__version__ = '1.0.12'
|
|
23
|
+
|
|
24
|
+
__user_agent__ = f'awslabs/mcp/postgres_mcp_server/{__version__}'
|
|
@@ -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
|