From b24951e3bd5479f2cd0e8dcfdd9f242783310948 Mon Sep 17 00:00:00 2001 From: Dominik Wombacher Date: Mon, 13 May 2024 01:41:33 +0200 Subject: [PATCH] feat(cfn): Custom Resource for SecretString SSM Parameters. IaC OpenTofu related resources (User, Roles, Bucket, DynamoDB ...) --- ...fn-custom-resource-aws-ssm-secretstring.py | 168 ++++++ cfn/iac-opentofu.yaml | 484 ++++++++++++++++++ 2 files changed, 652 insertions(+) create mode 100644 cfn/cfn-custom-resource-aws-ssm-secretstring.py create mode 100644 cfn/iac-opentofu.yaml diff --git a/cfn/cfn-custom-resource-aws-ssm-secretstring.py b/cfn/cfn-custom-resource-aws-ssm-secretstring.py new file mode 100644 index 0000000..1b79f91 --- /dev/null +++ b/cfn/cfn-custom-resource-aws-ssm-secretstring.py @@ -0,0 +1,168 @@ +# SPDX-FileCopyrightText: 2024 Dominik Wombacher +# +# SPDX-License-Identifier: MIT + +# Bootstrapped with 'Custom generic CloudFormation resource example' +# https://github.com/stelligent/cloudformation-custom-resources +# Inspired by 'Creating Secure String in AWS System Manager Parameter Store via AWS CloudFormation' +# https://rnanthan.medium.com/creating-secure-string-in-aws-system-manager-parameter-store-via-aws-cloudformation-f3ab62d9d4c3 + +import boto3 +import json +import logging +from urllib.request import build_opener, HTTPHandler, Request + +LOGGER = logging.getLogger() +LOGGER.setLevel(logging.DEBUG) + +def lambda_handler(event, context): + try: + LOGGER.info('REQUEST RECEIVED:\n %s', event) + LOGGER.info('REQUEST RECEIVED:\n %s', context) + + parameter_name = event['ResourceProperties']['Name'] + parameter_value = event['ResourceProperties']['Value'] + LOGGER.debug('Parameter Name:\n %s', parameter_name) + LOGGER.debug('Parameter Value:\n %s', parameter_value) + + if 'Description' in event['ResourceProperties']: + parameter_description = event['ResourceProperties']['Description'] + LOGGER.debug('Parameter Description:\n %s', parameter_description) + else: + parameter_description = "" + + if 'KmsKeyId' in event['ResourceProperties']: + kms_key_id = event['ResourceProperties']['KmsKeyId'] + LOGGER.debug('AWS KMS Key ID:\n %s', kms_key_id) + else: + kms_key_id = None + + if 'Tags' in event['ResourceProperties']: + parameter_tags = event['ResourceProperties']['Tags'] + LOGGER.debug('Parameter Tags:\n %s', parameter_tags) + else: + parameter_tags = [] + + client = boto3.client('ssm') + + if event['RequestType'] == 'Create': + LOGGER.info('CREATE!') + try: + if kms_key_id is not None: + client.put_parameter( + Name = parameter_name, + Value = parameter_value, + Description = parameter_description, + Type = 'SecureString', + KeyId = kms_key_id, + Tier = 'Standard', + Overwrite = False, + Tags = parameter_tags + ) + else: + client.put_parameter( + Name = parameter_name, + Value = parameter_value, + Description = parameter_description, + Type = 'SecureString', + Tier = 'Standard', + Overwrite = False, + Tags = parameter_tags + ) + send_response(event, context, "SUCCESS", + {"Message": "Resource creation successful!"}) + except client.exceptions.ParameterAlreadyExists: + LOGGER.error("Parameter already exists") + send_response(event, context, "FAILED", + {"Message": "Parameter already exists!"}) + except Exception as e: + LOGGER.error("Exception occurred", exc_info=True) + send_response(event, context, "FAILED", + {"Message": f"Exception occurred - {type(e).__name__} - Resource creation failed!"}) + + elif event['RequestType'] == 'Update': + LOGGER.info('UPDATE!') + try: + if kms_key_id is not None: + client.put_parameter( + Name = parameter_name, + Value = parameter_value, + Description = parameter_description, + Type = 'SecureString', + KeyId = kms_key_id, + Tier = 'Standard', + Overwrite = True + ) + else: + client.put_parameter( + Name = parameter_name, + Value = parameter_value, + Description = parameter_description, + Type = 'SecureString', + Tier = 'Standard', + Overwrite = True + ) + client.add_tags_to_resource( + ResourceType = "Parameter", + ResourceId = parameter_name, + Tags = parameter_tags + ) + + send_response(event, context, "SUCCESS", + {"Message": "Resource updated successful!"}) + except Exception as e: + LOGGER.error("Exception occurred", exc_info=True) + send_response(event, context, "FAILED", + {"Message": f"Exception occurred - {type(e).__name__} - Resource update failed!"}) + + elif event['RequestType'] == 'Delete': + LOGGER.info('DELETE!') + try: + client.delete_parameter(Name = parameter_name) + send_response(event, context, "SUCCESS", + {"Message": "Resource deletion successful!"} + ) + except client.exceptions.ParameterNotFound: + LOGGER.warn("Parameter not found.") + send_response(event, context, "SUCCESS", + {"Message": "Resource doesn't exist, not deletion necessary!"} + ) + except Exception as e: + LOGGER.error("Exception occurred", exc_info=True) + send_response(event, context, "FAILED", + {"Message": f"Exception occurred - {type(e).__name__} - Resource update failed!"}) + + else: + LOGGER.error('FAILED! Unexpected event received from CloudFormation.') + send_response(event, context, "FAILED", + {"Message": "Unexpected event received from CloudFormation"}) + + except Exception as e: + LOGGER.error('FAILED! Exception during processing.', exc_info=True) + send_response(event, context, "FAILED", { + "Message": f"Exception during processing - {type(e).__name__}"}) + + +def send_response(event, context, response_status, response_data): + '''Send a resource manipulation status response to CloudFormation''' + response_body = json.dumps({ + "Status": response_status, + "Reason": "See the details in CloudWatch Log Stream: " + context.log_stream_name, + "PhysicalResourceId": context.log_stream_name, + "StackId": event['StackId'], + "RequestId": event['RequestId'], + "LogicalResourceId": event['LogicalResourceId'], + "Data": response_data + }) + + LOGGER.info('ResponseURL: %s', event['ResponseURL']) + LOGGER.info('ResponseBody: %s', response_body) + + opener = build_opener(HTTPHandler) + request = Request(event['ResponseURL'], data=response_body) + request.add_header('Content-Type', '') + request.add_header('Content-Length', len(response_body)) + request.get_method = lambda: 'PUT' + response = opener.open(request) + LOGGER.info("Status code: %s", response.getcode()) + LOGGER.info("Status message: %s", response.msg) diff --git a/cfn/iac-opentofu.yaml b/cfn/iac-opentofu.yaml new file mode 100644 index 0000000..586cae7 --- /dev/null +++ b/cfn/iac-opentofu.yaml @@ -0,0 +1,484 @@ +# IacOpenTofuUserAndStateBackendResources + +AWSTemplateFormatVersion: 2010-09-09 +Description: IAC OpenTofu User and State Backend resources + +Parameters: + KMSStackName: + Type: String + Serial: + Type: Number + Default: 1 + Description: Increment this to rotate IAM User credentials + ParameterNameForAccessKey: + Type: String + Default: /wombelix/sideprojects/infrastructure/iac-opentufu-accesskey + ParameterNameForSecretKey: + Type: String + Default: /wombelix/sideprojects/infrastructure/iac-opentufu-secretkey + CustomResourceLambdaName: + Type: String + Default: CfnCustomResourceSecureStringInParamterStore + BucketNameForOpenTofu: + Type: String + Default: wombelix-sideprojects-iac-opentofu + +Resources: + CustomResourceLambdaRole: + Type: AWS::IAM::Role + Properties: + RoleName: CustomResourceLambdaRole + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Service: + - "lambda.amazonaws.com" + Action: + - "sts:AssumeRole" + + CustomResourceLambdaRolePolicy: + Type: AWS::IAM::RolePolicy + Properties: + RoleName: !Ref CustomResourceLambdaRole + PolicyName: CustomResourceLambdaPolicy + PolicyDocument: + Statement: + # Grant access to Parameter Store resources + - Effect: Allow + Action: + - ssm:PutParameter + - ssm:DeleteParameter + Resource: + - !Sub arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:${ParameterNameForAccessKey} + - !Sub arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:${ParameterNameForSecretKey} + # Grant access to CloudWatch + - Effect: Allow + Action: + - logs:CreateLogGroup + Resource: + - !Sub arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:* + - Effect: Allow + Action: + - logs:CreateLogStream + - logs:PutLogEvents + Resource: + - !Sub arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/${CustomResourceLambdaName}:* + # Grant access to the encryption key + - Effect: Allow + Action: + - kms:DescribeKey + - kms:Encrypt + - kms:CreateGrant + Resource: + - Fn::ImportValue: + !Sub ${KMSStackName}-KMSKeyBackendEncryptionArn + + CustomResourceLambdaFunction: + Type: AWS::Lambda::Function + Properties: + Description: Create/Update/Delete SecureString Values in SSM Parameter Store + FunctionName: !Ref CustomResourceLambdaName + Role: !GetAtt CustomResourceLambdaRole.Arn + Timeout: 10 + Handler: index.lambda_handler + Runtime: python3.12 + Code: + ZipFile: | + # SPDX-FileCopyrightText: 2024 Dominik Wombacher + # + # SPDX-License-Identifier: MIT + + # Bootstrapped with 'Custom generic CloudFormation resource example' + # https://github.com/stelligent/cloudformation-custom-resources + # Inspired by 'Creating Secure String in AWS System Manager Parameter Store via AWS CloudFormation' + # https://rnanthan.medium.com/creating-secure-string-in-aws-system-manager-parameter-store-via-aws-cloudformation-f3ab62d9d4c3 + + import boto3 + import json + import logging + from urllib.request import build_opener, HTTPHandler, Request + + LOGGER = logging.getLogger() + LOGGER.setLevel(logging.DEBUG) + + def lambda_handler(event, context): + try: + LOGGER.info('REQUEST RECEIVED:\n %s', event) + LOGGER.info('REQUEST RECEIVED:\n %s', context) + + parameter_name = event['ResourceProperties']['Name'] + parameter_value = event['ResourceProperties']['Value'] + LOGGER.debug('Parameter Name:\n %s', parameter_name) + LOGGER.debug('Parameter Value:\n %s', parameter_value) + + if 'Description' in event['ResourceProperties']: + parameter_description = event['ResourceProperties']['Description'] + LOGGER.debug('Parameter Description:\n %s', parameter_description) + else: + parameter_description = "" + + if 'KmsKeyId' in event['ResourceProperties']: + kms_key_id = event['ResourceProperties']['KmsKeyId'] + LOGGER.debug('AWS KMS Key ID:\n %s', kms_key_id) + else: + kms_key_id = None + + if 'Tags' in event['ResourceProperties']: + parameter_tags = event['ResourceProperties']['Tags'] + LOGGER.debug('Parameter Tags:\n %s', parameter_tags) + else: + parameter_tags = [] + + client = boto3.client('ssm') + + if event['RequestType'] == 'Create': + LOGGER.info('CREATE!') + try: + if kms_key_id is not None: + client.put_parameter( + Name = parameter_name, + Value = parameter_value, + Description = parameter_description, + Type = 'SecureString', + KeyId = kms_key_id, + Tier = 'Standard', + Overwrite = False, + Tags = parameter_tags + ) + else: + client.put_parameter( + Name = parameter_name, + Value = parameter_value, + Description = parameter_description, + Type = 'SecureString', + Tier = 'Standard', + Overwrite = False, + Tags = parameter_tags + ) + send_response(event, context, "SUCCESS", + {"Message": "Resource creation successful!"}) + except client.exceptions.ParameterAlreadyExists: + LOGGER.error("Parameter already exists") + send_response(event, context, "FAILED", + {"Message": "Parameter already exists!"}) + except Exception as e: + LOGGER.error("Exception occurred", exc_info=True) + send_response(event, context, "FAILED", + {"Message": f"Exception occurred - {type(e).__name__} - Resource creation failed!"}) + + elif event['RequestType'] == 'Update': + LOGGER.info('UPDATE!') + try: + if kms_key_id is not None: + client.put_parameter( + Name = parameter_name, + Value = parameter_value, + Description = parameter_description, + Type = 'SecureString', + KeyId = kms_key_id, + Tier = 'Standard', + Overwrite = True + ) + else: + client.put_parameter( + Name = parameter_name, + Value = parameter_value, + Description = parameter_description, + Type = 'SecureString', + Tier = 'Standard', + Overwrite = True + ) + client.add_tags_to_resource( + ResourceType = "Parameter", + ResourceId = parameter_name, + Tags = parameter_tags + ) + + send_response(event, context, "SUCCESS", + {"Message": "Resource updated successful!"}) + except Exception as e: + LOGGER.error("Exception occurred", exc_info=True) + send_response(event, context, "FAILED", + {"Message": f"Exception occurred - {type(e).__name__} - Resource update failed!"}) + + elif event['RequestType'] == 'Delete': + LOGGER.info('DELETE!') + try: + client.delete_parameter(Name = parameter_name) + send_response(event, context, "SUCCESS", + {"Message": "Resource deletion successful!"} + ) + except client.exceptions.ParameterNotFound: + LOGGER.warn("Parameter not found.") + send_response(event, context, "SUCCESS", + {"Message": "Resource doesn't exist, not deletion necessary!"} + ) + except Exception as e: + LOGGER.error("Exception occurred", exc_info=True) + send_response(event, context, "FAILED", + {"Message": f"Exception occurred - {type(e).__name__} - Resource update failed!"}) + + else: + LOGGER.error('FAILED! Unexpected event received from CloudFormation.') + send_response(event, context, "FAILED", + {"Message": "Unexpected event received from CloudFormation"}) + + except Exception as e: + LOGGER.error('FAILED! Exception during processing.', exc_info=True) + send_response(event, context, "FAILED", { + "Message": f"Exception during processing - {type(e).__name__}"}) + + + def send_response(event, context, response_status, response_data): + '''Send a resource manipulation status response to CloudFormation''' + response_body = json.dumps({ + "Status": response_status, + "Reason": "See the details in CloudWatch Log Stream: " + context.log_stream_name, + "PhysicalResourceId": context.log_stream_name, + "StackId": event['StackId'], + "RequestId": event['RequestId'], + "LogicalResourceId": event['LogicalResourceId'], + "Data": response_data + }) + + LOGGER.info('ResponseURL: %s', event['ResponseURL']) + LOGGER.info('ResponseBody: %s', response_body) + + opener = build_opener(HTTPHandler) + request = Request(event['ResponseURL'], data=response_body) + request.add_header('Content-Type', '') + request.add_header('Content-Length', len(response_body)) + request.get_method = lambda: 'PUT' + response = opener.open(request) + LOGGER.info("Status code: %s", response.getcode()) + LOGGER.info("Status message: %s", response.msg) + + IAMUserIACOpenTofu: + Type: AWS::IAM::User + Properties: + UserName: iac-opentofu + Tags: + - Key: Environment + Value: Production + - Key: Usage + Value: IAC-OpenTofu + + IAMUserIACOpenTofuAccessKey: + Type: AWS::IAM::AccessKey + Properties: + Serial: !Ref Serial + Status: Active + UserName: !Ref IAMUserIACOpenTofu + + IAMUserIACOpenTofuAccessKeyParameterStore: + Type: Custom::CreateSecureStringInParamterStore + Properties: + ServiceToken: !Sub arn:${AWS::Partition}:lambda:${AWS::Region}:${AWS::AccountId}:function:${CustomResourceLambdaName} + Name: iac-opentofu-accesskey + Value: !Ref IAMUserIACOpenTofuAccessKey + Description: !Sub "The access key for IAM User ${IAMUserIACOpenTofu}" + KmsKeyId: !ImportValue + 'Fn::Sub': ${KMSStackName}-KMSKeyBackendEncryptionArn + Tags: + "Environment": "Production" + "Usage": "IAC-OpenTofu" + + IAMUserIACOpenTofuSecretKeyParameterStore: + Type: Custom::CreateSecureStringInParamterStore + Properties: + ServiceToken: !Sub arn:${AWS::Partition}:lambda:${AWS::Region}:${AWS::AccountId}:function:${CustomResourceLambdaName} + Name: iac-opentofu-secretkey + Value: !Ref IAMUserIACOpenTofuAccessKey + Description: !Sub "The secret key for IAM User ${IAMUserIACOpenTofu}" + KmsKeyId: !ImportValue + 'Fn::Sub': ${KMSStackName}-KMSKeyBackendEncryptionArn + Tags: + "Environment": "Production" + "Usage": "IAC-OpenTofu" + + OpenTofuRemoteBackendBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: !Ref BucketNameForOpenTofu + BucketEncryption: + ServerSideEncryptionConfiguration: + - ServerSideEncryptionByDefault: + SSEAlgorithm: 'aws:kms' + KMSMasterKeyID: !ImportValue + 'Fn::Sub': ${KMSStackName}-KMSKeyBackendEncryptionArn + BucketKeyEnabled: true + PublicAccessBlockConfiguration: + BlockPublicAcls: true + BlockPublicPolicy: true + IgnorePublicAcls: true + RestrictPublicBuckets: true + LifecycleConfiguration: + Rules: + - Id: TransitionsForStateFile + Status: Enabled + Transitions: + - TransitionInDays: 7 + StorageClass: INTELLIGENT_TIERING + NoncurrentVersionTransitions: + - TransitionInDays: 1 + StorageClass: GLACIER + VersioningConfiguration: + Status: Enabled + LoggingConfiguration: + LogFilePrefix: 'opentofu-logs/' + Tags: + - Key: "Environment" + Value: "Production" + - Key: "Usage" + Value: "IAC-OpenTofu" + DeletionPolicy: Retain + UpdateReplacePolicy: Retain + + OpenTofuRemoteBackendBucketBucketPolicy: + Type: AWS::S3::BucketPolicy + Properties: + Bucket: !Ref OpenTofuRemoteBackendBucket + PolicyDocument: + Statement: + - Sid: DenyDeletingOpenTofuStateFiles + Effect: Deny + Principal: "*" + Action: "s3:DeleteObject" + Resource: + - !Sub "${OpenTofuRemoteBackendBucket.Arn}/*" + - Sid: RestrictToTLSRequestsOnly + Principal: "*" + Action: "s3:*" + Effect: Deny + Resource: + - !GetAtt OpenTofuRemoteBackendBucket.Arn + - !Sub "${OpenTofuRemoteBackendBucket.Arn}/*" + Condition: + Bool: + "aws:SecureTransport": "false" + + OpenTofuRemoteBackendDDB: + Type: AWS::DynamoDB::Table + Properties: + TableName: iac-opentofu-remote-backend + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: LockID + AttributeType: S + KeySchema: + - AttributeName: LockID + KeyType: HASH + SSESpecification: + SSEEnabled: true + SSEType: KMS + KMSMasterKeyId: !ImportValue + 'Fn::Sub': ${KMSStackName}-KMSKeyBackendEncryptionArn + PointInTimeRecoverySpecification: + PointInTimeRecoveryEnabled: false + Tags: + - Key: "Environment" + Value: "Production" + - Key: "Usage" + Value: "IAC-OpenTofu" + DeletionPolicy: Delete + UpdateReplacePolicy: Retain + + OpenTofuRemoteBackendRole: + Type: AWS::IAM::Role + Properties: + RoleName: OpenTofuRemoteBackendRole + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + AWS: + - !Sub arn:${AWS::Partition}:iam::${AWS::AccountId}:${IAMUserIACOpenTofu}' + Action: 'sts:AssumeRole' + + OpenTofuRemoteBackendRolePolicy: + Type: AWS::IAM::RolePolicy + Properties: + RoleName: !Ref OpenTofuRemoteBackendRole + PolicyName: OpenTofuRemoteBackendRolePolicy + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: 's3:ListBucket' + Resource: + - !GetAtt OpenTofuRemoteBackendBucket.Arn + - Effect: Allow + Action: + - s3:GetObject + - s3:PutObject + Resource: + - !Sub "${OpenTofuRemoteBackendBucket.Arn}/opentofu-states/*" + - Effect: Allow + Action: + - dynamodb:DescribeTable + - dynamodb:GetItem + - dynamodb:PutItem + - dynamodb:DeleteItem + Resource: + - !GetAtt OpenTofuRemoteBackendDDB.Arn + + OpenTofuStateEncryptionRole: + Type: AWS::IAM::Role + Properties: + RoleName: OpenTofuStateEncryptionRole + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + AWS: + - !Sub arn:${AWS::Partition}:iam::${AWS::AccountId}:${IAMUserIACOpenTofu}' + Action: 'sts:AssumeRole' + + OpenTofuStateEncryptionRolePolicy: + Type: AWS::IAM::RolePolicy + Properties: + RoleName: !Ref OpenTofuStateEncryptionRole + PolicyName: OpenTofuStateEncryptionRolePolicy + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - kms:Encrypt + - kms:Decrypt + - kms:ReEncrypt* + - kms:GenerateDataKey* + - kms:DescribeKey + Resource: + - Fn::ImportValue: + !Sub ${KMSStackName}-KMSKeyBackendEncryptionArn + +Outputs: + OpenTofuBackendBucketName: + Value: !Ref OpenTofuRemoteBackendBucket + Export: + Name: !Sub ${AWS::StackName}-OpenTofuBackendBucketName + + OpenTofuBackendRegion: + Value: !Ref AWS::Region + Export: + Name: !Sub ${AWS::StackName}-OpenTofuBackendRegion + + OpenTofuBackendDynamoDBArn: + Value: !GetAtt OpenTofuRemoteBackendDDB.Arn + Export: + Name: !Sub ${AWS::StackName}-OpenTofuBackendDynamoDBArn + + OpenTofuStateEncryptionRoleArn: + Value: !GetAtt OpenTofuStateEncryptionRole.Arn + Export: + Name: !Sub ${AWS::StackName}-OpenTofuStateEncryptionRoleArn + + OpenTofuRemoteBackendRoleArn: + Value: !GetAtt OpenTofuRemoteBackendRole.Arn + Export: + Name: !Sub ${AWS::StackName}-OpenTofuRemoteBackendRoleArn \ No newline at end of file -- 2.45.2