~wombelix/aws-sideprojects-infrastructure

b24951e3bd5479f2cd0e8dcfdd9f242783310948 — Dominik Wombacher 3 months ago b7e5e65 main
feat(cfn): Custom Resource for SecretString SSM Parameters. IaC OpenTofu related resources (User, Roles, Bucket, DynamoDB ...)
2 files changed, 652 insertions(+), 0 deletions(-)

A cfn/cfn-custom-resource-aws-ssm-secretstring.py
A cfn/iac-opentofu.yaml
A cfn/cfn-custom-resource-aws-ssm-secretstring.py => cfn/cfn-custom-resource-aws-ssm-secretstring.py +168 -0
@@ 0,0 1,168 @@
# SPDX-FileCopyrightText: 2024 Dominik Wombacher <dominik@wombacher.cc>
#
# 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)

A cfn/iac-opentofu.yaml => cfn/iac-opentofu.yaml +484 -0
@@ 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 <dominik@wombacher.cc>
          #
          # 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