AWS DevOps & Developer Productivity Blog

Moeve: Controlling resource deployment at scale with AWS CloudFormation Guard Hooks

This post is co-written with Rayco Martínez Hernández, Head of Cloud Governance at Moeve.

Moeve, formerly known as Cepsa, is a global integrated energy company with over 90 years of experience and more than 11,000 employees. Moeve is committed to driving Europe’s energy transition and accelerating decarbonization efforts. The company has embraced digital transformation to enhance energy efficiency, safety, and sustainability, focusing on investments in green hydrogen, second-generation biofuels, and ultra-fast electric vehicle charging infrastructure.

At Moeve, we decided to make AWS Control Tower our central governance tool and the foundation of our landing zone at the end of 2022. However, as an organization that wants to ensure that all deployed resources comply with the established requirements, it was challenging for us to remediate errors or vulnerabilities that arise when resources were deployed without compliance with our security definitions. The foundation of controls should be proactive. This is where AWS CloudFormation Hooks, along with other AWS measures like Service Control Policies (SCPs), play a differential role.

We have become familiar with CloudFormation Hooks thanks to the Guard Rules that we deploy as part of our proactive deployment policy on AWS. There are times when you want to block the deployment of Amazon API Gateway without security, Amazon VPC security groups with source 0.0.0.0/0, or with an ALL port range open. In these and other cases, we want to take a step further and create our own controls that are more in line with our own policies, and now we can do so in a simple and agile way, using the managed hooks launched in November 2024.

To be able to use these tools, it is essential, among other things, to ensure that resource deployments are only done through Infrastructure as Code (IaC) tools.

Would you like to know how we achieved it? Let’s get to it!

Background

At Moeve, we ensure that all our deployments within our organization are done through IaC. We enforce this by requiring all deployments to go through CloudFormation, which also allows us to enforce organizational policies using CloudFormation Guard Hooks.

However, for teams with more advanced technical expertise, we allow the use of the AWS Cloud Development Kit (CDK). The AWS CDK enables developers to define infrastructure using general-purpose programming languages, which facilitates code reuse, modular design, and better integration with existing development workflows. It provides high-level abstractions that accelerate the definition of common AWS patterns, while also allowing low-level control when needed. Even though it introduces an abstraction layer, the CDK synthesizes into standard CloudFormation templates, maintaining full compatibility with our governance and compliance mechanisms based on CloudFormation Guard Hooks.

We have several ways to perform deployments: directly launching actions against CloudFormation from the AWS Command Line Interface (CLI), through pipelines, or even by executing actions from code. However, the common basis for these deployments is that they cannot be done with Permission Sets associated with individuals. Users do not have access to deploy resources directly; they have read access and can assume a role that can deploy resources.

To make this more user-friendly, we have a small tool that assumes the role with enough permissions and deploys the template code we specify, with just a call like this:

cloudformation-deployer test.yml

It is crucial to make these controls easy for developers if you want them to comply with the established security measures.

Solution

At Moeve, as a best practice, we have delegated the management of Cloudformation StackSets to an AWS account different from the management account. In this account, we deploy an Amazon S3 bucket where we will store all the files for the CloudFormation Guard hooks or the AWS Lambda functions if they are Lambda type. In Figure 1, you can see a simplified version of our multi-account architecture when deploying resources using StackSets.

Multi-Account structure when building CloudFormation resources using CloudFormation StackSets. There are two central accounts (Management & Delegated Admin) and two child Accounts.

Figure 1. AWS CloudFormation StackSets configuration in a multi-Account environment

An example of a stack that manages the buckets can be seen below:

AWSTemplateFormatVersion: "2010-09-09"

Description: |
  This template creates an Amazon S3 bucket that you can use to deploy an AWS CloudFormation hook.

Parameters:
  GuardHooksBucketName:
    Type: String
    Description: Name for S3 bucket storing the CloudFormation Guard hooks
  OrgId:
    Description: Organization Id which is in the format o-xyzabcdefg
    Type: String
    AllowedPattern: ^o-[a-z0-9]{10,32}$
  
Resources:
  S3Bucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub ${GuardHooksBucketName}-${AWS::Region}-${AWS::AccountId}
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      LifecycleConfiguration:
        Rules:
          - Id: MultipartClean
            Status: Enabled
            AbortIncompleteMultipartUpload:
              DaysAfterInitiation: 5
      Tags:
        - Key: project
          Value: shared
      VersioningConfiguration:
        Status: Enabled

  S3BucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref S3Bucket
      PolicyDocument:
        Version: 2012-10-17
        Statement:
          - Sid: AllowSSLRequestsOnly
            Effect: Deny
            Action: s3:*
            Resource:
              - !Sub ${S3Bucket.Arn}/* # "arn:aws:s3:::arn:aws:s3:::<bucket-name>/<path>/*"
              - !Sub ${S3Bucket.Arn} # "arn:aws:s3:::arn:aws:s3:::<bucket-name>/<path>/"
            Principal: '*'
            Condition:
              Bool:
                aws:SecureTransport: 'false'
          - Sid: AllowOrgAccountsDeployAccess
            Effect: Allow
            Principal:
              AWS: "*"
            Action: "s3:GetObject"
            Resource: !Join
              - ""
              - - "arn:aws:s3:::"
                - !Ref S3Bucket
                - /*
            Condition:
              ForAnyValue:StringLike:
                "aws:PrincipalOrgPaths": !Sub "${OrgId}/*"

With this, we achieve having a centralized S3 bucket with an access policy according to which anyone within our organization can access and retrieve the objects. Versioning is configured to keep previous versions of the hooks we deploy. Then, in the bucket, we will store the files of our hooks, differentiating them by folders.

Child Accounts

For the child accounts, we will deploy a StackSet in the central account over the Organizational Units (OUs) that are defined. Auto-deployment will be configured so that all new Accounts added to the OU acquire these same hooks.

Check the example below where an S3 bucket will be deployed to store the logs of the hooks with the IAM role that the hooks will use, and two hooks: to evaluate the creation and update of API Gateway.

AWSTemplateFormatVersion: "2010-09-09"

Description: |
  Registers the hook in the AWS CloudFormation Private Registry and bucket to logs.

Parameters:
  CustomHooksLogBucketName:
    Type: String
    Description: Name for S3 bucket storing the CloudFormation Guard hook logs
  GuardHooksBucketName:
    Type: String
    Description: Name for S3 bucket storing the CloudFormation Guard hooks

Resources:
  # S3 bucket used to store logs generated by CloudFormation Guard hooks
  LogBucket:
    Type: AWS::S3::Bucket
    Properties:
     # Bucket name is built dynamically with account ID and region
      BucketName: !Sub ${CustomHooksLogBucketName}-${AWS::AccountId}-${AWS::Region}
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256	# Enforce AES256 encryption
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      LifecycleConfiguration:		# Manage bucket lifecycle
        Rules:
          - Id: MultipartClean
            Status: Enabled
            AbortIncompleteMultipartUpload:
              DaysAfterInitiation: 5	# Abort incomplete uploads after 5 days
          - Id: ExpireAfterOneWeek
            Status: Enabled
            Prefix: ""
            ExpirationInDays: 7	# Expire objects after 7 days
      Tags:
        - Key: project
          Value: shared
  # Bucket policy that enforces SSL-only access to the log bucket
  S3BucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref LogBucket
      PolicyDocument:
        Version: 2012-10-17
        Statement:
          - Sid: AllowSSLRequestsOnly
            Effect: Deny
            Action: s3:*
            Resource:
              - !Sub ${LogBucket.Arn}/* # "arn:aws:s3:::arn:aws:s3:::<bucket-name>/<path>/*" # Apply to all objects in the bucket
              - !Sub ${LogBucket.Arn} # "arn:aws:s3:::arn:aws:s3:::<bucket-name>/<path>/" # Apply to the bucket itself
            Principal: '*'
            Condition:
              Bool:
                aws:SecureTransport: 'false' 	# Deny if not using HTTPS
   # IAM Role that CloudFormation Guard hooks assume to interact with S3
  GuardHookRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:		# Trust policy for CloudFormation hooks
        Statement:
          - Action: sts:AssumeRole
            Condition:
              StringEquals:
                aws:SourceAccount: !Ref 'AWS::AccountId'
              StringLike:
                'aws:SourceArn': !Sub arn:${AWS::Partition}:cloudformation:${AWS::Region}:${AWS::AccountId}:type/hook/Moeve-*/*
            Effect: Allow
            Principal:
              Service:
                - hooks.cloudformation.amazonaws.com
                - resources.cloudformation.amazonaws.com
        Version: "2012-10-17"
      MaxSessionDuration: 8400
      Path: /
      Policies:
        - PolicyName: HookS3Policy		# Policy granting permissions to write/read from the log bucket
          PolicyDocument:
            Statement:
              - Action:
                - 's3:GetEncryptionConfiguration'
                - 's3:ListAllMyBuckets'
                - 's3:ListBucket'
                - 's3:GetObject'
                - 's3:PutObject'
                Effect: Allow
                Resource:
                  - !Sub arn:${AWS::Partition}:s3:::${LogBucket}
                  - !Sub arn:${AWS::Partition}:s3:::${LogBucket}/*
            Version: "2012-10-17"
        - PolicyName: HookGeneralS3Policy	# Policy granting read access to the bucket containing Guard rules
          PolicyDocument:
            Statement:
              - Action:
                - 's3:GetEncryptionConfiguration'
                - 's3:ListBucket'
                - 's3:GetObject'
                Effect: Allow
                Resource:
                  - !Sub arn:${AWS::Partition}:s3:::${GuardHooksBucketName}
                  - !Sub arn:${AWS::Partition}:s3:::${GuardHooksBucketName}/*
            Version: "2012-10-17"
      Tags:
        - Key: project
          Value: shared

   # Guard hook that validates API Gateway methods before provisioning
  GuardHookApiGateway: 
    Type: AWS::CloudFormation::GuardHook
    Properties:
      ExecutionRole: !GetAtt GuardHookRole.Arn
      LogBucket: !Ref LogBucket
      Alias: Moeve::ApiGatewayAuthorization::Coe
      FailureMode: FAIL	# Fail the operation if validation fails
      HookStatus: ENABLED
      TargetOperations:
        - RESOURCE	# Applies at the resource level
      TargetFilters:
        Actions:
          - CREATE		# Triggered on CREATE operations
        TargetNames:
          - AWS::ApiGateway::Method
        InvocationPoints:
          - PRE_PROVISION		# Runs before resource provisioning
      RuleLocation:
        Uri: !Sub
          - s3://${GuardS3Bucket}/${GuardS3File}	# Location of Guard rules in S3
          - GuardS3Bucket: !Ref GuardHooksBucketName
            GuardS3File: APIGATEWAY/ApiGatewaySecureMethod.guard
      StackFilters:
        FilteringCriteria: ALL
        StackNames:
          Exclude:
            - !Ref AWS::StackName	# Exclude the current stack from evaluation

   # Guard hook that validates API Gateway at stack UPDATE level
  GuardHookApiGatewaySR:
    Type: AWS::CloudFormation::GuardHook
    Properties:
      ExecutionRole: !GetAtt GuardHookRole.Arn
      LogBucket: !Ref LogBucket
      Alias: Moeve::ApiGatewayAuthorizationwithSR::Coe
      FailureMode: FAIL
      HookStatus: ENABLED
      TargetOperations:
        - STACK		# Applies at the stack level
      TargetFilters:
        Actions:
          - UPDATE		# Triggered on UPDATE operations
      RuleLocation:
        Uri: !Sub
          - s3://${GuardS3Bucket}/${GuardS3File}
          - GuardS3Bucket: !Ref GuardHooksBucketName
            GuardS3File: APIGATEWAY/ApiGatewaySecureMethodwithSR.guard
      StackFilters:
        FilteringCriteria: ALL
        StackNames:
          Exclude:
            - !Ref AWS::StackName

It is very important to configure backdoors to avoid blocking our own deployments when working with hooks deployed with CloudFormation StackSets across our organization or in OUs with multiple accounts. For this, we will configure a filter in the hook so that it does not activate on the stack that manages them. If this filter is not applied, it may happen that due to a misconfiguration, the stack cannot be updated and has to be deleted entirely.

Examples

In the following examples, we are going to ensure that no one can deploy an unsecured API Gateway, but at the same time, we do not want to break the current deployments. For this, we have defined two hooks.

When the custom hooks are triggered during deployment, they analyze the CloudFormation template and targets resources of type AWS::ApiGateway::Method, excluding methods that use the OPTIONS HTTP verb. The hooks apply two sequential validation rules to ensure that all API operations are properly secured.

The first rule checks whether each method defines either the ApiKeyRequired or the AuthorizationType property. If neither is present, the hook fails with a clear message: “Fallo en el paso 1 porque no existe ApiKeyRequired o AuthorizationType” (“Step 1 failed because there is no ApiKeyRequired or AuthorizationType”). If the first condition is satisfied, the second rule verifies whether the values themselves enforce security. Specifically, it checks that ApiKeyRequired is set to true, or that AuthorizationType is defined and not set to NONE. If not, the deployment is blocked again with the message: “Fallo en el paso 2, porque existe AuthorizationType o ApiKeyRequired, pero no son valores válidos.” (“Step 2 failed, because AuthorizationType or ApiKeyRequired exists, but they are not valid values.”).

If any of these rules fail, CloudFormation immediately stops the deployment before any resources are created. This avoids partial or insecure configurations and ensures consistency with organizational security standards. The error appears directly in the CloudFormation console or in CI/CD logs and includes the custom message defined in the hook, helping developers quickly identify the issue.

The development team receives immediate feedback during deployment, whether through their CI/CD pipeline (like CodePipeline or GitHub Actions) or the AWS console. The hook’s clear, custom messages make it easy to pinpoint and fix issues. In more mature environments, these failures can also trigger alerts, update dashboards, or create automated tickets, ensuring security enforcement without slowing down delivery.

The responsibility to fix the issue usually lies with the same team that wrote the template, since the error occurs before any infrastructure is provisioned. This approach allows teams to move quickly while still respecting the compliance and security controls in place. In cases where the issue is tied to a broader policy update, the resolution might involve collaboration with the platform or security team

Hook 1: Activates when an API Gateway is created. This hook checks at the resource level for Resources.Type == 'AWS::ApiGateway::Method', and if it does not meet the requirements, this resource cannot be deployed.

let api_gateway_method = Resources.*[ Type == 'AWS::ApiGateway::Method'
  Properties.HttpMethod != /(?i)options/  
]

#
# Primary Rules
#

rule api_gw_authorization_method_check when %api_gateway_method !empty {
    %api_gateway_method{
        Properties.ApiKeyRequired exists or
        Properties.AuthorizationType exists
        <<Fallo en el paso 1 Porque no existe ApiKeyRequired o AuthorizationType>>
    }
}

rule api_gw_authorization_method_check_2 when api_gw_authorization_method_check {
    %api_gateway_method{
        Properties.ApiKeyRequired == true or
        Properties.AuthorizationType != /(?i)none/
        <<Fallo en el paso 2, porque existe AuthorizationType o ApiKeyRequired, pero no son valores validos. >>
    }
}

Hook 2: Activates when an API Gateway is updated. This hook checks at the resource level for Resources.Type == 'AWS::ApiGateway::Method', and if it does not meet the requirements, this resource cannot be deployed.

let api_gateway_method = Resources.*[ Type == 'AWS::ApiGateway::Method'
    Properties.HttpMethod != /(?i)options/  
    Metadata.guard.SuppressedRules not exists or
    Metadata.guard.SuppressedRules.* != "API_GW_METHOD_AUTHORIZATION_TYPE_RULE"
]

#
# Primary Rules
#

rule api_gw_authorization_method_check when %api_gateway_method !empty {
    %api_gateway_method{
        Properties.ApiKeyRequired exists or
        Properties.AuthorizationType exists
        <<Fallo en el paso 1 Porque no existe ApiKeyRequired o AuthorizationType>>
    }
}


rule api_gw_authorization_method_check_2 when api_gw_authorization_method_check {
    %api_gateway_method{
        Properties.ApiKeyRequired == true or
        Properties.AuthorizationType != /(?i)none/
        <<Fallo en el paso 2, porque existe AuthorizationType o ApiKeyRequired, pero no son valores validos. >>
    }
}

To avoid breaking existing deployments, a backdoor is configured so that if specific metadata is applied, the hook will not activate, and deployments can continue without modifying the code beyond the metadata.

AWSTemplateFormatVersion: '2010-09-09'

Description: |
  This template creates an Amazon API Gateway REST API. It shows an example on how to suppress specific checks from CloudFormation Guard.

Resources:
  Api:
    Type: AWS::ApiGateway::RestApi
    Properties:
      Name: myAPI
  MyApiGatewayMethod:
    Type: AWS::ApiGateway::Method
    Metadata:
      guard:
        SuppressedRules:
          - API_GW_METHOD_AUTHORIZATION_TYPE_RULE
    Properties:
      RestApiId: !Ref Api
      ResourceId: !GetAtt Api.RootResourceId
      HttpMethod: GET
      AuthorizationType: 'NONE'
      ApiKeyRequired: false
      Integration:
        IntegrationHttpMethod: 'POST'
        Type: 'MOCK'

  OptionsMethod:
    Type: 'AWS::ApiGateway::Method'
    Properties:
      AuthorizationType: 'NONE'
      HttpMethod: 'OPTIONS'
      RestApiId: !Ref Api
      ResourceId: !GetAtt Api.RootResourceId
      Integration:
        IntegrationHttpMethod: 'POST'
        Type: 'MOCK'

Conclusion

The implementation of our own hook controls in CloudFormation has significantly improved infrastructure management and deployment within Moeve. This capability has allowed us to achieve a balance between flexibility, autonomy, and governance at scale. One of the key benefits is the automation of validations and specific controls, which helps us reduce manual errors and ensure greater consistency in deployments. This also enhances traceability and simplifies infrastructure maintenance, ensuring that our configurations adhere to established best practices.

From a security perspective, having custom rules allows us to strengthen regulatory compliance and minimize risks. We can ensure that only secure configurations aligned with our operational needs are implemented, reducing vulnerabilities and improving our cloud security posture. Preventing incorrect configurations from the start of the resource lifecycle also contributes to greater system stability and resilience. By avoiding infrastructure failures, we create more reliable environments that are prepared for growth.

Additionally, by ensuring optimal configurations during resource deployment, we optimize resource usage and avoid bottlenecks, resulting in better performance. This, in turn, helps us manage costs more effectively by preventing the use of unnecessary or oversized resources. Also, automating controls reduces manual intervention and minimizes waste, making our operations more efficient and sustainable over time.

In summary, the creation and management of our own hooks in CloudFormation provide us with full control over our infrastructure, ensuring an optimal balance between security, scalability, and operational efficiency. This strengthens our ability to innovate without compromising governance, enabling us to operate more agilely and securely in the cloud.

About the author(s)

Rayco Martínez Hernández

Rayco is Head of Cloud Governance at Moeve, building secure, compliant, and efficient cloud environments. He holds a degree in Computer Science and currently pursuing a Master’s degree in Cybersecurity. Passionate about cloud strategy and governance, Rayco focuses on adopting AWS with confidence and control. When not working on cloud initiatives, he enjoys exploring new technologies and spending time outdoors in the Canary Islands.

Pablo Sánchez Carmona

Pablo is a Senior Network Specialist Solutions Architect at AWS, where he helps customers to design secure, resilient and cost-effective networks. When not talking about Networking, Pablo can be found playing basketball or video-games. He holds a MSc in Electrical Engineering from the Royal Institute of Technology (KTH), and a Master’s degree in Telecommunications Engineering from the Polytechnic University of Catalonia (UPC).

Ignacio Rodríguez García

Ignacio is a Technical Account Manager at AWS. In his role, he provides advocacy and strategical technical guidance in order to help customers plan and build solutions using AWS best practices. He holds a MSc in Engineering from Télécom Paris, and a Master’s degree in Telecommunications Engineering from the Polytechnic University of Madrid (UPM). In his free time, Ignacio enjoys playing sports, spending time with his friends, and traveling.