diff --git a/.envrc.sample b/.envrc.sample index 44cf05d43..371d4d907 100644 --- a/.envrc.sample +++ b/.envrc.sample @@ -1,3 +1,3 @@ -export AWS_PROFILE=sandbox +export AWS_PROFILE=platform-sandbox export AWS_REGION=eu-west-2 export AWS_DEFAULT_REGION=eu-west-2 diff --git a/.gitignore b/.gitignore index f99ce4e20..ccc27f729 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,6 @@ *.DS_Store *.env* *venv* +*slack* +!slack_service.py __pycache__ diff --git a/.tool-versions b/.tool-versions index b8c855af4..a39fdd034 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,3 @@ terraform 1.9.6 -python 3.12.2 +python 3.12.4 +direnv 2.35.0 diff --git a/.trufflehogignore b/.trufflehogignore index 3bfd91a25..3864b2c5c 100644 --- a/.trufflehogignore +++ b/.trufflehogignore @@ -2,3 +2,7 @@ .playwright-browsers poetry.lock .terraform +slack_sdk +venv +.env +.zip \ No newline at end of file diff --git a/application-load-balancer/lambda_function/rotate_secret_lambda.py b/application-load-balancer/lambda_function/rotate_secret_lambda.py new file mode 100644 index 000000000..e6f72a6a5 --- /dev/null +++ b/application-load-balancer/lambda_function/rotate_secret_lambda.py @@ -0,0 +1,37 @@ +import boto3 +import logging + +from secret_rotator import SecretRotator + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +service_client = boto3.client('secretsmanager') + + +def lambda_handler(event, context): + secret_id = event.get('SecretId') + step = event.get('Step') + token = event.get('ClientRequestToken') + + if not secret_id: + logger.error("Unable to determine SecretId.") + raise ValueError("Unable to determine SecretId.") + + rotator = SecretRotator(logger=logger) + + if step == "createSecret": + logger.info("Entered createSecret step") + rotator.create_secret(service_client, secret_id, token) + elif step == "setSecret": + logger.info("Entered setSecret step") + rotator.set_secret(service_client, secret_id, token) + elif step == "testSecret": + logger.info("Entered testSecret step") + rotator.run_test_secret(service_client, secret_id, token, event.get('TestDomains', [])) + elif step == "finishSecret": + logger.info("Entered finishSecret step") + rotator.finish_secret(service_client, secret_id, token) + else: + logger.error(f"Invalid step parameter: {step}") + raise ValueError(f"Invalid step parameter: {step}") diff --git a/application-load-balancer/lambda_function/secret_rotator.py b/application-load-balancer/lambda_function/secret_rotator.py new file mode 100644 index 000000000..916d709e8 --- /dev/null +++ b/application-load-balancer/lambda_function/secret_rotator.py @@ -0,0 +1,626 @@ +import json +import os +import boto3 +import logging +import requests +import time +from typing import Tuple, Dict, Any, List, Optional +from slack_service import SlackNotificationService +from requests.exceptions import RequestException + +from botocore.exceptions import ClientError + +AWSPENDING = "AWSPENDING" +AWSCURRENT = "AWSCURRENT" + + +class SecretRotator: + def __init__(self, logger, **kwargs): + self.logger = logger + # Use provided values or default to provided Lambda environment variables + self.secret_id = kwargs.get('secret_id', os.environ.get('SECRETID')) + self.waf_acl_name = kwargs.get('waf_acl_name', os.environ.get('WAFACLNAME')) + self.waf_acl_id = kwargs.get('waf_acl_id', os.environ.get('WAFACLID')) + waf_rule_priority = kwargs.get('waf_rule_priority', os.environ.get('WAFRULEPRI')) + try: + self.waf_rule_priority = int(waf_rule_priority) + except (TypeError, ValueError): + self.waf_rule_priority = 0 + self.header_name = kwargs.get('header_name', os.environ.get('HEADERNAME')) + self.application = kwargs.get('application', os.environ.get('APPLICATION')) + self.environment = kwargs.get('environment', os.environ.get('ENVIRONMENT')) + self.role_arn = kwargs.get('role_arn', os.environ.get('ROLEARN')) + self.distro_list = kwargs.get('distro_list', os.environ.get('DISTROIDLIST')) + self.aws_account = kwargs.get('aws_account', os.environ.get('AWS_ACCOUNT')) + + slack_token = kwargs.get('slack_token', os.environ.get('SLACK_TOKEN')) + slack_channel = kwargs.get('slack_channel', os.environ.get('SLACK_CHANNEL')) + self.slack_service = None + if slack_token and slack_channel: + self.slack_service = SlackNotificationService(slack_token, slack_channel, self.aws_account) + self.waf_sleep_duration = int(kwargs.get('waf_sleep_duration', os.environ.get('WAF_SLEEP_DURATION', 75))) + + def get_cloudfront_client(self) -> boto3.client: + sts = boto3.client('sts') + credentials = sts.assume_role(RoleArn=self.role_arn, RoleSessionName='rotation_session')["Credentials"] + return boto3.client('cloudfront', + aws_access_key_id=credentials["AccessKeyId"], + aws_secret_access_key=credentials["SecretAccessKey"], + aws_session_token=credentials["SessionToken"]) + + def get_deployed_distributions(self) -> List[Dict[str, Any]]: + client = self.get_cloudfront_client() + paginator = client.get_paginator("list_distributions") + matching_distributions = [] + + for page in paginator.paginate(): + for distribution in page.get("DistributionList", {}).get("Items", []): + aliases = distribution.get("Aliases", {}).get("Items", []) + if any(domain in aliases for domain in self.distro_list.split(",")): + matching_distributions.append({ + "Id": distribution["Id"], + "Origin": distribution['Origins']['Items'][0]['DomainName'], + "Domain": distribution['Aliases']['Items'][0] + }) + self.logger.info(f"Matched cloudfront distributions: {matching_distributions}") + return matching_distributions + + def get_waf_acl(self) -> Dict[str, Any]: + client = boto3.client('wafv2') + return client.get_web_acl(Name=self.waf_acl_name, Scope='REGIONAL', Id=self.waf_acl_id) + + def _create_byte_match_statement(self, search_string: str) -> Dict[str, Any]: + return { + 'ByteMatchStatement': { + 'FieldToMatch': {'SingleHeader': {'Name': self.header_name}}, + 'PositionalConstraint': 'EXACTLY', + 'SearchString': search_string, + 'TextTransformations': [{'Type': 'NONE', 'Priority': 0}] + } + } + + def update_waf_acl(self, new_secret: str, prev_secret: str) -> None: + client = boto3.client('wafv2') + waf_acl = self.get_waf_acl() + lock_token = waf_acl['LockToken'] + metric_name = f"{self.application}-{self.environment}-XOriginVerify" + rule = { + 'Name': f"{self.application}{self.environment}XOriginVerify", + 'Priority': self.waf_rule_priority, + 'Action': {'Allow': {}}, + 'VisibilityConfig': { + 'SampledRequestsEnabled': True, + 'CloudWatchMetricsEnabled': True, + 'MetricName': metric_name + }, + 'Statement': { + 'OrStatement': { + 'Statements': [ + self._create_byte_match_statement(new_secret), + self._create_byte_match_statement(prev_secret) + ] + } + } + } + + new_rules = [rule] + [r for r in waf_acl['WebACL']['Rules'] if r['Priority'] != self.waf_rule_priority] + self.logger.info("Updating WAF WebACL with new rules.") + + response = client.update_web_acl( + Name=self.waf_acl_name, + Scope='REGIONAL', + Id=self.waf_acl_id, + LockToken=lock_token, + DefaultAction={'Block': {}}, + Description='CloudFront Origin Verify', + VisibilityConfig={ + 'SampledRequestsEnabled': True, + 'CloudWatchMetricsEnabled': True, + 'MetricName': metric_name + }, + Rules=new_rules + ) + + if response['ResponseMetadata']['HTTPStatusCode'] == 200: + self.logger.info("WAF WebACL rules updated") + + def get_cf_distro(self, distro_id: str) -> Dict: + """ + Fetches the CloudFront distribution details. + """ + client = self.get_cloudfront_client() + return client.get_distribution(Id=distro_id) + + + def get_cf_distro_config(self, distro_id: str) -> Dict: + """ + Fetches the configuration of a CloudFront distribution. + """ + client = self.get_cloudfront_client() + return client.get_distribution_config(Id=distro_id) + + + def update_cf_distro(self, distro_id: str, header_value: str) -> Dict: + """ + Updates the custom headers for a CloudFront distribution. + + Args: + distro_id (str): The ID of the CloudFront distribution. + header_value (str): The header value to set for the custom header. + """ + client = self.get_cloudfront_client() + + if not self.is_distribution_deployed(distro_id): + self.logger.error(f"Distribution Id: {distro_id} status is not Deployed.") + raise ValueError(f"Distribution Id: {distro_id} status is not Deployed.") + + dist_config = self.get_cf_distro_config(distro_id) + + self.update_custom_headers(dist_config, header_value) + + # Update the distribution + try: + return self.apply_distribution_update(client, distro_id, dist_config) + + except RuntimeError as e: + self.logger.error(f"Failed to update custom headers for distribution Id {distro_id}: {e}") + raise + + + def is_distribution_deployed(self, distro_id: str) -> bool: + """ + Checks if the CloudFront distribution is deployed. + + """ + dist_status = self.get_cf_distro(distro_id) + return 'Deployed' in dist_status['Distribution']['Status'] + + def update_custom_headers(self, dist_config: Dict, header_value: str) -> bool: + """ + Updates or creates given custom header in the distribution config. + Returns True if any headers were updated or created. + """ + header_count = 0 + + for origin in dist_config['DistributionConfig']['Origins']['Items']: + if 'CustomHeaders' not in origin or origin['CustomHeaders']['Quantity'] == 0: + self.logger.info(f"No custom headers exist. Creating new custom header for origin: {origin['Id']}") + origin['CustomHeaders'] = { + 'Quantity': 1, + 'Items': [{ + 'HeaderName': self.header_name, + 'HeaderValue': header_value + }] + } + header_count += 1 + continue + + found_header = False + for header in origin['CustomHeaders']['Items']: + if header['HeaderName'] == self.header_name: + self.logger.info(f"Updating existing custom header for origin: {origin['Id']}") + header['HeaderValue'] = header_value + found_header = True + header_count += 1 + break + + if not found_header: + self.logger.info(f"Adding new custom header to existing headers for origin: {origin['Id']}") + origin['CustomHeaders']['Items'].append({ + 'HeaderName': self.header_name, + 'HeaderValue': header_value + }) + origin['CustomHeaders']['Quantity'] += 1 + header_count += 1 + + return header_count > 0 + + def apply_distribution_update(self, client, distro_id: str, dist_config: Dict) -> Dict: + """ + Applies the distribution update to CloudFront. + """ + try: + response = client.update_distribution( + Id=distro_id, + IfMatch=dist_config['ResponseMetadata']['HTTPHeaders']['etag'], + DistributionConfig=dist_config['DistributionConfig'] + ) + + status_code = response['ResponseMetadata']['HTTPStatusCode'] + + if status_code == 200: + self.logger.info(f"CloudFront distribution {distro_id} updated successfully") + else: + self.logger.warning(f"Failed to update CloudFront distribution {distro_id}. Status code: {status_code}") + raise RuntimeError(f"Failed to update CloudFront distribution {distro_id}. Status code: {status_code}") + + return response + + except Exception as e: + self.logger.error(f"Error updating CloudFront distribution {distro_id}: {str(e)}") + raise + + def process_cf_distributions_and_WAF_rules(self, matching_distributions, pending_secret, current_secret): + """ + Process CloudFront distributions based on whether the custom header is already present. + If the custom header is missing, it will be added to the distribution. + Updates the WAF ACL & the CloudFront distributions with the AWSPENDING & AWSCURRENT secret values. + This method should set the AWSPENDING secret in the service that the secret belongs to. + Sleep default 75 seconds to allow resources to update + """ + all_have_header = True # Assume all distributions have the header initially + + for distro in matching_distributions: + distro_id = distro['Id'] + dist_config = self.get_cf_distro_config(distro_id) + + # Track if the header was found or added in this distribution + header_found = False + + for origin in dist_config['DistributionConfig']['Origins']['Items']: + # Check if 'Items' exists inside 'CustomHeaders', if not, initialise it + if 'Items' not in origin['CustomHeaders']: + self.logger.info(f"CustomHeaders empty for origin {origin['Id']}, adding custom header.") + origin['CustomHeaders']['Items'] = [{ + 'HeaderName': self.header_name, + 'HeaderValue': pending_secret['HEADERVALUE'] + }] + self.logger.info(f"Custom header added in CloudFront distribution: {origin['Id']}") + # Mark that we modified this distribution by adding the header + all_have_header = False + else: + # If 'Items' exists, check if the custom header is present + header_found = any( + header['HeaderName'] == self.header_name + for header in origin['CustomHeaders']['Items'] + ) + + # If the header is not found, add it + if not header_found: + self.logger.info(f"Custom header not found in origin {origin}, adding header.") + origin['CustomHeaders']['Items'].append({ + 'HeaderName': self.header_name, + 'HeaderValue': pending_secret['HEADERVALUE'] + }) + + self.logger.info(f"Custom header found/added in CloudFront distribution: {origin}") + all_have_header = False # Mark this as needing update, since we added it + + # If header is found in the Items, we can break out of the loop for this origin + if header_found: + break + + # If header was not found and added in any of the origins, we mark all_have_header as False + if not header_found: + all_have_header = False + + if all_have_header: + # If all CF distributions have the header, update WAF rule first + self.logger.info("Updating WAF rule first. All CloudFront distributions already have custom header.") + self.update_waf_acl(pending_secret['HEADERVALUE'], current_secret['HEADERVALUE']) + + # Sleep for default 75 seconds for regional WAF config propagation + self.logger.info(f"Sleeping for {self.waf_sleep_duration} seconds for WAF rule propagation.") + time.sleep(self.waf_sleep_duration) + + # Update each CloudFront distribution + for distro in matching_distributions: + try: + self.logger.info(f"Updating CloudFront distribution {distro['Id']}.") + self.update_cf_distro(distro['Id'], pending_secret['HEADERVALUE']) + except Exception as e: + self.logger.error(f"Failed to update distribution {distro['Id']}: {e}") + raise + + if not all_have_header: + # If not all CF distributions had the header, update WAF last + self.logger.info("Not all CloudFront distributions have the header. Updating WAF last.") + self.update_waf_acl(pending_secret['HEADERVALUE'], current_secret['HEADERVALUE']) + + # Sleep for default 75 seconds for regional WAF config propagation + self.logger.info(f"Sleeping for {self.waf_sleep_duration} seconds for WAF rule propagation.") + time.sleep(self.waf_sleep_duration) + + def run_test_origin_access(self, url: str, secret: str) -> bool: + try: + response = requests.get( + url, + headers={self.header_name: secret}, + timeout=(5, 10) # 3-second connection timeout, 5-second read timeout + ) + self.logger.info(f"Testing URL, {url} - response code, {response.status_code}") + + # Log additional response details for debugging + if response.status_code != 200: + self.logger.error(f"Non-200 response for URL {url}") + self.logger.error(f"Response Status Code: {response.status_code}") + self.logger.error(f"Response Headers: {response.headers}") + try: + self.logger.error(f"Response Content: {response.text[:500]}") # Limit content to first 500 chars + except Exception as content_error: + self.logger.error(f"Could not log response content: {str(content_error)}") + + return response.status_code == 200 + + except requests.exceptions.ConnectionError as conn_err: + self.logger.error(f"Connection error for URL {url}") + self.logger.error(f"Connection Error Details: {str(conn_err)}") + # Log more specific connection error details + if hasattr(conn_err, 'response'): + self.logger.error(f"Connection Error Response: {conn_err.response}") + return False + + except requests.exceptions.Timeout as timeout_err: + self.logger.error(f"Timeout error for URL {url}") + self.logger.error(f"Timeout Error Details: {str(timeout_err)}") + return False + + except requests.exceptions.TooManyRedirects as redirect_err: + self.logger.error(f"Too many redirects for URL {url}") + self.logger.error(f"Redirect Error Details: {str(redirect_err)}") + return False + + except requests.exceptions.RequestException as e: + self.logger.error(f"Unhandled request error for URL {url}") + self.logger.error(f"Error Type: {type(e).__name__}") + self.logger.error(f"Error Details: {str(e)}") + + # Additional context if available + if hasattr(e, 'response'): + try: + self.logger.error(f"Error Response Status Code: {e.response.status_code}") + self.logger.error(f"Error Response Headers: {e.response.headers}") + self.logger.error(f"Error Response Content: {e.response.text[:500]}") # Limit content to first 500 chars + except Exception as log_err: + self.logger.error(f"Could not log error response details: {str(log_err)}") + + return False + + except Exception as unexpected_err: + self.logger.error(f"Unexpected error testing URL {url}") + self.logger.error(f"Unexpected Error Type: {type(unexpected_err).__name__}") + self.logger.error(f"Unexpected Error Details: {str(unexpected_err)}") + return False + + def get_secrets(self, service_client, arn: str) -> Tuple[Dict, Dict]: + metadata = service_client.describe_secret(SecretId=arn) + version_stages = metadata.get("VersionIdsToStages", {}) + current_version = None + pending_version = None + + for version, stages in version_stages.items(): + if AWSCURRENT in stages: + current_version = version + self.logger.info(f"Found AWSCURRENT version: {version}") + if AWSPENDING in stages: + pending_version = version + self.logger.info(f"Found AWSPENDING version: {version}") + + if not current_version: + raise ValueError("No AWSCURRENT version found") + + if not pending_version: + raise ValueError("No AWSPENDING version found") + + try: + current = service_client.get_secret_value( + SecretId=arn, + VersionId=current_version, + VersionStage=AWSCURRENT + ) + + pending = service_client.get_secret_value( + SecretId=arn, + VersionId=pending_version, + VersionStage=AWSPENDING + ) + except service_client.exceptions.ResourceNotFoundException as e: + self.logger.error(f"Failed to retrieve secret values: {e}") + raise + + pending_secret = json.loads(pending['SecretString']) + current_secret = json.loads(current['SecretString']) + + return pending_secret, current_secret + + def create_secret(self, service_client, arn, token): + # Make sure the current secret exists + try: + service_client.get_secret_value( + SecretId=arn, + VersionStage="AWSCURRENT" + ) + self.logger.info("Successfully retrieved AWSCURRENT version for secret") + + except service_client.exceptions.ResourceNotFoundException: + self.logger.error("AWSCURRENT version does not exist for secret") + + try: + service_client.get_secret_value( + SecretId=arn, + VersionId=token, + VersionStage="AWSPENDING" + ) + self.logger.info("Successfully retrieved AWSPENDING version for secret") + except service_client.exceptions.ResourceNotFoundException: + # Generate a random password for AWSPENDING + passwd = service_client.get_random_password(ExcludePunctuation=True) + self.logger.info("Generate new password for AWSPENDING for secret") + + try: + service_client.put_secret_value( + SecretId=arn, + ClientRequestToken=token, + SecretString=json.dumps({"HEADERVALUE": passwd['RandomPassword']}), + VersionStages=['AWSPENDING']) + self.logger.info("Successfully created AWSPENDING version stage and secret value for secret") + except Exception as e: + self.logger.error(f"Failed to create AWSPENDING version for secret. Error: {e}") + raise + + def set_secret(self, service_client, arn, token): + """Set the secret + Updates the WAF ACL & the CloudFront distributions with the AWSPENDING & AWSCURRENT secret values. + This method should set the AWSPENDING secret in the service that the secret belongs to. + Sleep default 75 seconds to allow resources to update + Args: + service_client (client): The secrets manager service client + arn (string): The secret ARN or other identifier + token (string): The ClientRequestToken associated with the secret version + """ + # Confirm CloudFront distributions are in Deployed state + matching_distributions = self.get_deployed_distributions() + + if not matching_distributions: + self.logger.error("No matching distributions found. Cannot update Cloudfront distributions or WAF ACLs") + raise ValueError("No matching distributions found. Cannot update Cloudfront distributions or WAF ACLs") + + for distro in matching_distributions: + self.logger.info(f"Getting status of distro: {distro['Id']}") + + if not self.is_distribution_deployed(distro['Id']): + self.logger.error(f"Distribution Id, {distro['Id']} status is not Deployed.") + raise ValueError(f"Distribution Id, {distro['Id']} status is not Deployed.") + else: + self.logger.info(f"Distro {distro['Id']} is deployed") + + # Obtain secret value for AWSPENDING + pending = service_client.get_secret_value( + SecretId=arn, + VersionId=token, + VersionStage="AWSPENDING" + ) + + # Obtain secret value for AWSCURRENT + metadata = service_client.describe_secret(SecretId=arn) + for version in metadata["VersionIdsToStages"]: + self.logger.info("Getting AWSCURRENT version") + if "AWSCURRENT" in metadata["VersionIdsToStages"][version]: + currenttoken = version + current = service_client.get_secret_value( + SecretId=arn, + VersionId=currenttoken, + VersionStage="AWSCURRENT" + ) + + pendingsecret = json.loads(pending['SecretString']) + currentsecret = json.loads(current['SecretString']) + + # Update regional WAF WebACL rule and CloudFront custom header with AWSPENDING and AWSCURRENT + try: + self.process_cf_distributions_and_WAF_rules(matching_distributions, pendingsecret, currentsecret) + + except ClientError as e: + self.logger.error(f"Error updating resources: {e}") + raise ValueError( + f"Failed to update resources CloudFront Distro Id {distro['Id']} , WAF WebACL Id {self.waf_acl_id}") from e + + def run_test_secret(self, service_client, arn, token, test_domains=[]): + """Test the secret + This method validates that the AWSPENDING secret works in the service. + If any tests fail: + 1. Attempts to send a Slack notification (notification failure won't stop the rotation process) + 2. If Lambda event contains key TestDomains and provided domains to test, then you can trigger a Slack notification to the configured Slack channel + """ + test_failures = [] + + # Check for TestDomains key in the Lambda event - currently only used in console to test Slack message is emitted + if test_domains: + self.logger.info("TestDomains key exists in Lambda event - testing provided dummy domains only") + for test_domain in test_domains: + self.logger.info(f"Testing dummy distro: {test_domain}") + error_msg = f"Simulating test failure for domain: http://{test_domain}" + self.logger.error(error_msg) + test_failures.append({'domain': test_domain, 'error': error_msg, }) + + else: + # Obtain secret value for AWSPENDING + pending = service_client.get_secret_value( + SecretId=arn, + VersionId=token, + VersionStage="AWSPENDING" + ) + + # Obtain secret value for AWSCURRENT + metadata = service_client.describe_secret(SecretId=arn) + for version in metadata["VersionIdsToStages"]: + if "AWSCURRENT" in metadata["VersionIdsToStages"][version]: + currenttoken = version + current = service_client.get_secret_value( + SecretId=arn, + VersionId=currenttoken, + VersionStage="AWSCURRENT" + ) + self.logger.info("Getting AWSCURRENT version") + + pendingsecret = json.loads(pending['SecretString']) + currentsecret = json.loads(current['SecretString']) + + secrets = [pendingsecret['HEADERVALUE'], currentsecret['HEADERVALUE']] + + distro_list = self.get_deployed_distributions() + for distro in distro_list: + self.logger.info(f"Testing distro: {distro['Id']}") + try: + for s in secrets: + if self.run_test_origin_access("http://" + distro["Domain"], s): + self.logger.info(f"Domain ok for http://{distro['Domain']}") + pass + else: + error_msg = f"Tests failed for URL, http://{distro['Domain']}" + self.logger.error(error_msg) + test_failures.append({ + 'domain': distro["Domain"], + 'secret_type': 'PENDING' if s == pendingsecret['HEADERVALUE'] else 'CURRENT', + 'error': 'Connection failed or non-200 response' + }) + except Exception as e: + error_msg = f"Error testing {distro}: {str(e)}" + self.logger.error(error_msg) + test_failures.append({ + 'domain': distro["Domain"], + 'error': str(e) + }) + + if test_failures: + if self.slack_service: + try: + self.slack_service.send_test_failures( + failures=test_failures, + environment=self.environment, + application=self.application + ) + except Exception as e: + self.logger.error(f"Failure to send Slack notification{str(e)}") + + def finish_secret(self, service_client, arn, token): + """Finish the secret + This method finalises the rotation process by marking the secret version passed in as the AWSCURRENT secret. + Args: + service_client (client): The secrets manager service client + arn (string): The secret ARN or other identifier + token (string): The ClientRequestToken associated with the secret version + Raises: + ResourceNotFoundException: If the secret with the specified arn does not exist + """ + + # First describe the secret to get the current version + metadata = service_client.describe_secret(SecretId=arn) + current_version_token = None + for version in metadata["VersionIdsToStages"]: + if AWSCURRENT in metadata["VersionIdsToStages"][version]: + if version == token: + # The correct version is already marked as current, return + self.logger.info(f"finishSecret: Version {version} already marked as AWSCURRENT") + return + current_version_token = version + break + + # Finalize by staging the secret version current + service_client.update_secret_version_stage( + SecretId=arn, + VersionStage=AWSCURRENT, + MoveToVersionId=token, + RemoveFromVersionId=current_version_token + ) + self.logger.info(f"finishSecret: Successfully set AWSCURRENT stage to version {token}") diff --git a/application-load-balancer/lambda_function/slack_service.py b/application-load-balancer/lambda_function/slack_service.py new file mode 100644 index 000000000..e8583bd1c --- /dev/null +++ b/application-load-balancer/lambda_function/slack_service.py @@ -0,0 +1,108 @@ +import logging +import json +import requests +from typing import List, Dict, Tuple + +logger = logging.getLogger() + + +class SlackNotificationService: + def __init__(self, slack_token: str, slack_channel: str, aws_account: str): + self.slack_token = slack_token + self.slack_channel = slack_channel + self.aws_account = aws_account + self.slack_api_url = "https://slack.com/api/chat.postMessage" + + def send_test_failures(self, failures: List[Dict], environment: str, application: str, channel: str = None) -> None: + """ + Send formatted test failure notifications to Slack + """ + try: + message_blocks, summary_text, failure_text = self._build_failure_message(failures, environment, application) + logger.info("Attempt sending Slack notification for test failures") + + # Send the initial message to Slack + response = self._send_message(channel or self.slack_channel, summary_text, message_blocks) + + # If there's overflow text, send it as threaded messages + max_length = 2900 + if len(failure_text) > max_length: + for i in range(0, len(failure_text), max_length): + thread_text = failure_text[i:i + max_length] + self._send_message(channel or self.slack_channel, thread_text, thread_ts=response['ts']) + logger.info("Additional failure details sent in thread") + + except Exception as e: + logger.error(f"Failed to send Slack notification: {str(e)}") + + def _send_message(self, channel: str, text: str, blocks: List[Dict] = None, thread_ts: str = None) -> Dict: + """ + Sends a message to Slack using the Slack API. + """ + headers = { + "Content-type": "application/json; charset=utf-8", + "Authorization": f"Bearer {self.slack_token}", + } + + payload = { + "channel": channel, + "text": text, + "blocks": blocks or [], + } + + if thread_ts: + payload["thread_ts"] = thread_ts + + response = requests.post(self.slack_api_url, headers=headers, data=json.dumps(payload)) + + if response.status_code != 200 or not response.json().get('ok', False): + raise ValueError(f"Error sending message to Slack: {response.status_code}, {response.text}") + + return response.json() + + def _build_failure_message(self, failures: List[Dict], environment: str, application: str) -> Tuple[List[Dict], str, str]: + message_blocks = [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": ":rotating_light: Secret Rotation Test Failures" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*Environment:* {environment}\n*Application:* {application}\n*Application AWS account:* {self.aws_account}" + } + } + ] + + # Prepare the detailed failure text + failure_text = "" + for failure in failures: + entry = f"• Domain: {failure['domain']}\n" + if 'secret_type' in failure: + entry += f" Secret Type: {failure['secret_type']}\n" + if 'error' in failure: + entry += f" Error: {failure['error']}\n" + failure_text += entry + + # Truncate main message if needed and indicate continuation in thread + max_length = 2900 + truncated_text = failure_text[:max_length] + if len(failure_text) > max_length: + truncated_text += "\n*...continued in thread.*" + + message_blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*Failures:*\n{truncated_text}" + } + }) + + # Summary text for accessibility + summary_text = f"Secret Rotation Test Failures for {application} in {environment}" + + return message_blocks, summary_text, failure_text diff --git a/application-load-balancer/lambda_function/test_rotate_secret_lambda.py b/application-load-balancer/lambda_function/test_rotate_secret_lambda.py new file mode 100644 index 000000000..d52ad5845 --- /dev/null +++ b/application-load-balancer/lambda_function/test_rotate_secret_lambda.py @@ -0,0 +1,1153 @@ +import pytest +from unittest.mock import patch, MagicMock, call +from botocore.exceptions import ClientError +import json +import time +import boto3 +from secret_rotator import SecretRotator + +@pytest.fixture(scope="session") +def rotator(): + """ + Creates a SecretRotator instance with test configuration. + """ + mock_logger = MagicMock() + + return SecretRotator( + logger = mock_logger, + waf_acl_name="test-waf-id", + waf_acl_id="test-waf-acl", + waf_rule_priority="0", + header_name="x-origin-verify", + application="test-app", + environment="test", + role_arn="arn:aws:iam::123456789012:role/test-role", + distro_list="example.com,example2.com", + waf_sleep_duration=75 + ) + +class TestCloudFrontSessionManagement: + """ + Tests for CloudFront session management and credentials handling. + """ + + def test_assumes_correct_role_for_cloudfront_access(self, rotator): + """ + The system must use STS to assume the correct role before accessing CloudFront. + """ + # Given STS credentials for CloudFront access + mock_credentials = { + "Credentials": { + "AccessKeyId": "test-access-key", + "SecretAccessKey": "test-secret-key", + "SessionToken": "test-session-token" + } + } + + mock_sts = MagicMock() + mock_cloudfront = MagicMock() + + mock_sts.assume_role.return_value = mock_credentials + + with patch('boto3.client') as mock_boto3_client: + mock_boto3_client.side_effect = lambda service, **kwargs: \ + mock_sts if service == 'sts' else mock_cloudfront + + client = rotator.get_cloudfront_client() + + mock_sts.assume_role.assert_called_once_with( + RoleArn="arn:aws:iam::123456789012:role/test-role", + RoleSessionName='rotation_session' + ) + + mock_boto3_client.assert_has_calls([ + call('sts'), + call('cloudfront', + aws_access_key_id="test-access-key", + aws_secret_access_key="test-secret-key", + aws_session_token="test-session-token" + ) + ]) + +class TestDistributionDiscovery: + """ + Tests for CloudFront distribution discovery and filtering. + """ + + def test_identifies_distributions_that_need_secret_updates(self, rotator): + """ + The lambda must identify all CloudFront distributions that need secret updates + based on their domain aliases. + """ + # Given a mix of relevant and irrelevant distributions + mock_distributions = { + "DistributionList": { + "Items": [ + { + "Id": "DIST1", + "Origins": {"Items": [{"DomainName": "origin1.example.com"}]}, + "Aliases": {"Items": ["example.com"]} # Should match + }, + { + "Id": "DIST2", + "Origins": {"Items": [{"DomainName": "origin2.example.com"}]}, + "Aliases": {"Items": ["example2.com"]} # Should match + }, + { + "Id": "DIST3", + "Origins": {"Items": [{"DomainName": "origin3.example.com"}]}, + "Aliases": {"Items": ["unrelated.com"]} # Should not match + } + ] + } + } + + expected_result = [ + { + "Id": "DIST1", + "Origin": "origin1.example.com", + "Domain": "example.com" + }, + { + "Id": "DIST2", + "Origin": "origin2.example.com", + "Domain": "example2.com" + } + ] + + rotator.get_cloudfront_client = MagicMock() + mock_client = MagicMock() + mock_paginator = MagicMock() + mock_client.get_paginator.return_value = mock_paginator + mock_paginator.paginate.return_value = [mock_distributions] + + rotator.get_cloudfront_client.return_value = mock_client + + result = rotator.get_deployed_distributions() + + assert result == expected_result, f"Expected: {expected_result}, but got: {result}" + + mock_client.get_paginator.assert_called_once_with("list_distributions") + +class TestWAFManagement: + """ + Tests for WAF rule management during secret rotation. + These tests verify that WAF rules are updated correctly to ensure zero-downtime rotation. + """ + + def test_waf_contains_both_secrets_during_rotation(self, rotator): + """ + During rotation, the WAF rule must accept both old and new secrets. + """ + current_rules = { + "WebACL": { + "Rules": [ + {"Priority": 1, "Name": "ExistingRule1"}, + {"Priority": 2, "Name": "ExistingRule2"} + ] + }, + "LockToken": "test-lock-token" + } + + with patch("boto3.client") as mock_boto_client: + mock_client = MagicMock() + mock_boto_client.return_value = mock_client + + rotator.get_waf_acl = MagicMock() + rotator.get_waf_acl.return_value = current_rules + + # When updating the WAF ACL with both secrets + rotator.update_waf_acl("new-secret", "old-secret") + + # Then the update should preserve existing rules + call_args = mock_client.update_web_acl.call_args[1] + existing_rules = [r for r in call_args['Rules'] + if r.get('Name') in ['ExistingRule1', 'ExistingRule2']] + assert len(existing_rules) == 2, "Must preserve existing WAF rules" + + # And include both secrets in an OR condition + secret_rule = next(r for r in call_args['Rules'] + if r.get('Name') == 'test-apptest' + 'XOriginVerify') + statements = secret_rule['Statement']['OrStatement']['Statements'] + header_values = [s['ByteMatchStatement']['SearchString'] for s in statements] + assert "new-secret" in header_values, "New secret must be in WAF rule" + assert "old-secret" in header_values, "Old secret must be in WAF rule" + + def test_waf_update_is_atomic_with_lock_token(self, rotator): + """ + WAF updates must be atomic using a lock token to prevent concurrent modifications. + """ + # Given a WAF with a lock token + current_rules = { + "WebACL": {"Rules": []}, + "LockToken": "original-lock-token" + } + + with patch("boto3.client") as mock_boto_client: + mock_client = MagicMock() + mock_boto_client.return_value = mock_client + + rotator.get_waf_acl = MagicMock() + rotator.get_waf_acl.return_value = current_rules + + # When updating the WAF + rotator.update_waf_acl("new-secret", "old-secret") + + # Then it should use the lock token + call_args = mock_client.update_web_acl.call_args[1] + assert call_args['LockToken'] == "original-lock-token", \ + "Must use lock token for atomic updates" + +class TestDistributionUpdates: + """ + Tests for CloudFront distribution updates during secret rotation. + """ + + def test_only_updates_deployed_distributions(self, rotator): + """ + Distribution updates must only proceed when the distribution is in 'Deployed' state. + """ + rotator.get_cloudfront_client = MagicMock() + mock_client = MagicMock() + + mock_client.get_distribution.return_value = { + "Distribution": {"Status": "InProgress"} + } + + rotator.get_cloudfront_client.return_value = mock_client + + with pytest.raises(ValueError) as exc_info: + rotator.update_cf_distro("DIST1", "new-header-value") + + assert "status is not Deployed" in str(exc_info.value) + mock_client.update_distribution.assert_not_called() + + def test_updates_all_matching_custom_headers(self, rotator): + """ + All custom headers matching our header name must be updated with the new secret. + """ + # get_cf_distro - used to determine the status of the distribution + mock_dist_status = { + "Distribution": {"Status": "Deployed"} + } + # get_cf_distro_config - returns a distribution with multiple origins and headers + mock_dist_config = { + "DistributionConfig": { + "Origins": { + "Items": [ + { + "Id": "origin1", + "CustomHeaders": { + "Quantity": 2, + "Items": [ + { + "HeaderName": "x-origin-verify", + "HeaderValue": "old-value" + }, + { + "HeaderName": "other-header", + "HeaderValue": "unchanged" + } + ] + } + }, + { + "Id": "origin2", + "CustomHeaders": { + "Quantity": 1, + "Items": [ + { + "HeaderName": "x-origin-verify", + "HeaderValue": "old-value" + } + ] + } + } + ] + } + }, + "ResponseMetadata": { + "HTTPHeaders": {"etag": "test-etag"} + } + } + + rotator.get_cloudfront_client = MagicMock() + mock_client = MagicMock() + rotator.get_cloudfront_client.return_value = mock_client + + rotator.get_cf_distro = MagicMock() + rotator.get_cf_distro.return_value = mock_dist_status + + rotator.get_cf_distro_config = MagicMock() + rotator.get_cf_distro_config.return_value = mock_dist_config + + mock_client.update_distribution.return_value = { + 'ResponseMetadata': { + 'HTTPStatusCode': 200 + } + } + + rotator.update_cf_distro("DIST1", "new-value") + + # Then it should update all matching headers + update_call = mock_client.update_distribution.call_args[1] + updated_config = update_call['DistributionConfig'] + + # Verify all x-origin-verify headers were updated + for origin in updated_config['Origins']['Items']: + for header in origin['CustomHeaders']['Items']: + if header['HeaderName'] == 'x-origin-verify': + assert header['HeaderValue'] == "new-value", \ + f"Header not updated for origin {origin['Id']}" + else: + assert header['HeaderValue'] == "unchanged", \ + "Non-matching headers should not be modified" + + + def test_runtime_error_for_failed_distribution_update(self, rotator): + """ + Test that a RuntimeError is raised when the CloudFront distribution update fails + (i.e., when the status code is not 200). + """ + # get_cf_distro - used to determine the status of the distribution + mock_dist_status = { + "Distribution": {"Status": "Deployed"} + } + mock_dist_config = { + "DistributionConfig": { + "Origins": { + "Items": [ + { + "Id": "origin1", + "CustomHeaders": { + "Quantity": 2, + "Items": [ + { + "HeaderName": "x-origin-verify", + "HeaderValue": "old-value" + }, + { + "HeaderName": "other-header", + "HeaderValue": "unchanged" + } + ] + } + } + ] + } + }, + "ResponseMetadata": { + "HTTPHeaders": {"etag": "test-etag"} + } + } + + rotator.get_cloudfront_client = MagicMock() + mock_client = MagicMock() + rotator.get_cloudfront_client.return_value = mock_client + + rotator.get_cf_distro = MagicMock() + rotator.get_cf_distro.return_value = mock_dist_status + + rotator.get_cf_distro_config = MagicMock() + rotator.get_cf_distro_config.return_value = mock_dist_config + + mock_client.update_distribution.return_value = { + 'ResponseMetadata': { + 'HTTPStatusCode': 500 + } + } + + with pytest.raises(RuntimeError) as excinfo: + rotator.update_cf_distro("DIST1", "new-value") + + assert "Failed to update CloudFront distribution" in str(excinfo.value) + assert "Status code: 500" in str(excinfo.value) + + + def test_value_error_for_non_deployed_distribution(self, rotator): + """ + Test that a ValueError is raised when the distribution is not deployed + (i.e., when the `is_distribution_deployed` method returns False). + """ + rotator.get_cloudfront_client = MagicMock() + mock_client = MagicMock() + rotator.get_cloudfront_client.return_value = mock_client + + rotator.is_distribution_deployed = MagicMock() + rotator.is_distribution_deployed.return_value = False + + # Test that the ValueError is raised when update_cf_distro is called + with pytest.raises(ValueError) as excinfo: + rotator.update_cf_distro("DIST1", "new-value") + + # checl exception type is correct + if not isinstance(excinfo.value, ValueError): + pytest.fail(f"Expected ValueError, but got {type(excinfo.value).__name__} instead.") + + assert "Distribution Id: DIST1 status is not Deployed." in str(excinfo.value) + + +class TestProcessCloudFrontDistributions: + + def test_all_distributions_already_have_header(self, rotator): + """ + Test scenario where all distributions already have the custom header. + Verify WAF update happens first, and distributions are still updated. + """ + mock_logger = MagicMock() + rotator.logger = mock_logger + + matching_distributions = [ + {'Id': 'dist1'}, + {'Id': 'dist2'} + ] + pending_secret = {'HEADERVALUE': 'new-secret'} + current_secret = {'HEADERVALUE': 'old-secret'} + + def mock_get_cf_distro_config(distro_id): + return { + 'DistributionConfig': { + 'Origins': { + 'Items': [{ + 'Id': 'origin1', + 'CustomHeaders': { + 'Items': [{ + 'HeaderName': 'x-origin-verify', + 'HeaderValue': 'existing-secret' + }] + } + }] + } + } + } + + rotator.get_cf_distro_config = mock_get_cf_distro_config + + rotator.update_waf_acl = MagicMock() + rotator.update_cf_distro = MagicMock() + time.sleep = MagicMock() + + rotator.process_cf_distributions_and_WAF_rules( + matching_distributions, + pending_secret, + current_secret + ) + + rotator.update_waf_acl.assert_called_once_with( + pending_secret['HEADERVALUE'], + current_secret['HEADERVALUE'] + ) + + time.sleep.assert_called_once_with(rotator.waf_sleep_duration) + + rotator.update_cf_distro.assert_has_calls([ call('dist1', pending_secret['HEADERVALUE']), call('dist2', pending_secret['HEADERVALUE']), ], any_order=False) + + + mock_logger.info.assert_any_call( + "Updating WAF rule first. All CloudFront distributions already have custom header." + ) + + expected_calls = [ + call.update_waf_acl(pending_secret['HEADERVALUE'], current_secret['HEADERVALUE']), + call.update_cf_distro('dist1', pending_secret['HEADERVALUE']), + call.update_cf_distro('dist2', pending_secret['HEADERVALUE']), + ] + actual_calls = rotator.update_waf_acl.mock_calls + rotator.update_cf_distro.mock_calls + assert actual_calls == expected_calls + + + + def test_some_distributions_missing_header(self, rotator): + """ + Test scenario where some distributions are missing the custom header. + Verify header is added and all distributions are updated. + """ + mock_logger = MagicMock() + rotator.logger = mock_logger + + matching_distributions = [ + {'Id': 'dist1'}, + {'Id': 'dist2'} + ] + pending_secret = {'HEADERVALUE': 'new-secret'} + current_secret = {'HEADERVALUE': 'old-secret'} + + def mock_get_cf_distro_config(distro_id): + if distro_id == 'dist1': + return { + 'DistributionConfig': { + 'Origins': { + 'Items': [{ + 'Id': 'origin1', + 'CustomHeaders': { + 'Items': [] + } + }] + } + } + } + return { + 'DistributionConfig': { + 'Origins': { + 'Items': [{ + 'Id': 'origin1', + 'CustomHeaders': { + 'Items': [{ + 'HeaderName': 'x-origin-verify', + 'HeaderValue': 'existing-secret' + }] + } + }] + } + } + } + + rotator.get_cf_distro_config = mock_get_cf_distro_config + + rotator.update_waf_acl = MagicMock() + rotator.update_cf_distro = MagicMock() + time.sleep = MagicMock() + + rotator.process_cf_distributions_and_WAF_rules( + matching_distributions, + pending_secret, + current_secret + ) + + assert rotator.update_cf_distro.call_count == len(matching_distributions) + for distro in matching_distributions: + rotator.update_cf_distro.assert_any_call(distro['Id'], pending_secret['HEADERVALUE']) + + rotator.update_waf_acl.assert_called_once_with( + pending_secret['HEADERVALUE'], + current_secret['HEADERVALUE'] + ) + + assert time.sleep.call_count == 1 + + mock_logger.info.assert_any_call( + "Not all CloudFront distributions have the header. Updating WAF last." + ) + + expected_calls = [ + call.update_cf_distro('dist1', pending_secret['HEADERVALUE']), + call.update_cf_distro('dist2', pending_secret['HEADERVALUE']), + call.update_waf_acl(pending_secret['HEADERVALUE'], current_secret['HEADERVALUE']), + ] + + actual_calls = rotator.update_cf_distro.mock_calls + rotator.update_waf_acl.mock_calls + assert actual_calls == expected_calls + + + + +class TestSecretManagement: + """ + Tests for AWS Secrets Manager operations during rotation. + Tests verify the creation and management of secrets during the rotation process. + """ + + def test_new_secret_created_when_no_pending_exists(self, rotator): + """ + Create a new pending secret if none exists. + """ + mock_service_client = MagicMock() + mock_service_client.exceptions.ResourceNotFoundException = Exception + + def mock_get_secret_value(**kwargs): + if kwargs["VersionStage"] == "AWSCURRENT": + # Simulate AWSCURRENT exists + return {"SecretString": json.dumps({"HEADERVALUE": "current-secret"})} + elif kwargs["VersionStage"] == "AWSPENDING": + # Simulate AWSPENDING does not exist + raise mock_service_client.exceptions.ResourceNotFoundException( + {"Error": {"Code": "ResourceNotFoundException"}}, + "GetSecretValue" + ) + raise ValueError("Unexpected call to get_secret_value") + + mock_service_client.get_secret_value.side_effect = mock_get_secret_value + + mock_service_client.get_random_password.return_value = {"RandomPassword": "new-secret"} + + mock_service_client.put_secret_value.return_value = {} + + with patch("boto3.client", return_value=mock_service_client): + rotator.create_secret(mock_service_client, "test-arn", "test-token") + + mock_service_client.get_secret_value.assert_has_calls([ + call(SecretId="test-arn", VersionStage="AWSCURRENT"), # First call for AWSCURRENT + call(SecretId="test-arn", VersionId="test-token", VersionStage="AWSPENDING") # Second call for AWSPENDING + ]) + + mock_service_client.put_secret_value.assert_called_once_with( + SecretId="test-arn", + ClientRequestToken="test-token", + SecretString='{"HEADERVALUE": "new-secret"}', + VersionStages=['AWSPENDING'] + ) + + + def test_awscurrent_not_found_logs_error(self, rotator): + """ + Test that a ResourceNotFoundException for AWSCURRENT logs the appropriate error message. + """ + mock_logger = MagicMock() + rotator.logger = mock_logger + mock_service_client = MagicMock() + + mock_service_client.exceptions.ResourceNotFoundException = Exception + + # Configure the `get_secret_value` method to raise an exception for AWSCURRENT + def mock_get_secret_value(**kwargs): + if kwargs["VersionStage"] == "AWSCURRENT": + raise mock_service_client.exceptions.ResourceNotFoundException( + {"Error": {"Code": "ResourceNotFoundException"}}, + "GetSecretValue" + ) + + return {"SecretString": json.dumps({"HEADERVALUE": "current-secret"})} + + mock_service_client.get_secret_value.side_effect = mock_get_secret_value + + rotator.create_secret(mock_service_client, "test-arn", "test-token") + + mock_logger.error.assert_called_with("AWSCURRENT version does not exist for secret") + + + +class TestRotationProcess: + """ + Tests for the complete secret rotation process. + Verifying the end-to-end rotation workflow and its components. + """ + + def test_set_secret_updates_waf_first_when_all_distributions_have_header(self, rotator): + """ + The WAF ACL should be updated before the distributions when all distributions + already have the header. + """ + # Mock distributions + mock_distributions = [ + {"Id": "DIST1", "Origin": "origin1.example.com"}, + {"Id": "DIST2", "Origin": "origin2.example.com"} + ] + + mock_get_distro_with_header = { + "DistributionConfig": { + "Origins": { + "Items": [ + { + "CustomHeaders": { + "Items": [{"HeaderName": rotator.header_name, "HeaderValue": "current-secret"}] + } + } + ] + } + } + } + + # Mock secrets and metadata + mock_metadata = { + "VersionIdsToStages": { + "current-version": ["AWSCURRENT"], + "test-token": ["AWSPENDING"] + } + } + mock_credentials = { + "Credentials": { + "AccessKeyId": "test-access-key", + "SecretAccessKey": "test-secret-key", + "SessionToken": "test-session-token" + } + } + mock_pending_secret = {"SecretString": json.dumps({"HEADERVALUE": "new-secret"})} + mock_current_secret = {"SecretString": json.dumps({"HEADERVALUE": "current-secret"})} + + mock_boto_client = MagicMock() + mock_boto_client.get_secret_value.side_effect = [ + mock_pending_secret, + mock_current_secret + ] + mock_boto_client.describe_secret.return_value = mock_metadata + mock_boto_client.assume_role.return_value = mock_credentials + + time.sleep = MagicMock() + rotator.is_distribution_deployed = MagicMock(return_value=True) + rotator.get_cf_distro_config = MagicMock(return_value=mock_get_distro_with_header) + rotator.update_cf_distro = MagicMock() + rotator.update_waf_acl = MagicMock() + rotator.get_deployed_distributions = MagicMock(return_value=mock_distributions) + + rotator.set_secret(mock_boto_client, "test-arn", "test-token") + + # Expect update_waf_acl to be called first with the new and current secret values + rotator.update_waf_acl.assert_called_once_with("new-secret", "current-secret") + + # Ensure that update_cf_distro is called in the correct sequence + rotator.update_cf_distro.assert_has_calls([ + call("DIST1", "new-secret"), + call("DIST2", "new-secret") + ], any_order=False) + + time.sleep.assert_called_once_with(rotator.waf_sleep_duration) + + def test_set_secret_updates_distributions_first_when_some_distributions_lack_header(self, rotator): + """ + Distributions should be updated first, and then the WAF ACL should be updated + when some distributions are missing the header. + """ + # Mock distributions + mock_distributions = [ + {"Id": "DIST1", "Origin": "origin1.example.com"}, + {"Id": "DIST2", "Origin": "origin2.example.com"} + ] + + mock_get_distro_without_header = { + "DistributionConfig": { + "Origins": { + "Items": [] + } + } + } + mock_get_distro_with_header = { + "DistributionConfig": { + "Origins": { + "Items": [ + { + "CustomHeaders": { + "Items": [{"HeaderName": rotator.header_name, "HeaderValue": "current-secret"}] + } + } + ] + } + } + } + + # Mock secrets and metadata + mock_metadata = { + "VersionIdsToStages": { + "current-version": ["AWSCURRENT"], + "test-token": ["AWSPENDING"] + } + } + mock_credentials = { + "Credentials": { + "AccessKeyId": "test-access-key", + "SecretAccessKey": "test-secret-key", + "SessionToken": "test-session-token" + } + } + mock_pending_secret = {"SecretString": json.dumps({"HEADERVALUE": "new-secret"})} + mock_current_secret = {"SecretString": json.dumps({"HEADERVALUE": "current-secret"})} + + # Mock boto3 client + mock_boto_client = MagicMock() + mock_boto_client.get_secret_value.side_effect = [ + mock_pending_secret, + mock_current_secret + ] + mock_boto_client.describe_secret.return_value = mock_metadata + mock_boto_client.assume_role.return_value = mock_credentials + + time.sleep = MagicMock() + rotator.is_distribution_deployed = MagicMock(return_value=True) + rotator.get_cf_distro_config = MagicMock(side_effect=[ + mock_get_distro_without_header, # DIST1 + mock_get_distro_with_header # DIST2 + ]) + rotator.update_cf_distro = MagicMock() + rotator.update_waf_acl = MagicMock() + rotator.get_deployed_distributions = MagicMock(return_value=mock_distributions) + + rotator.set_secret(mock_boto_client, "test-arn", "test-token") + + rotator.update_cf_distro.assert_has_calls([ + call("DIST1", "new-secret"), + call("DIST2", "new-secret") + ], any_order=False) + + + rotator.update_waf_acl.assert_called_once_with("new-secret", "current-secret") + + time.sleep.assert_called_once_with(rotator.waf_sleep_duration) + + + def test_secret_validates_all_origins_with_both_secrets(self, rotator): + """ + The test_secret phase must verify all origin servers accept both old and new secrets. + """ + mock_distributions = [ + {"Id": "DIST1", "Domain": "domain1.example.com"}, + {"Id": "DIST2", "Domain": "domain2.example.com"} + ] + + mock_pending_secret = { + "SecretString": json.dumps({"HEADERVALUE": "new-secret"}) + } + mock_current_secret = { + "SecretString": json.dumps({"HEADERVALUE": "current-secret"}) + } + mock_metadata = { + "VersionIdsToStages": { + "current-version": ["AWSCURRENT"], + "test-token": ["AWSPENDING"] + } + } + + mock_service_client = MagicMock() + mock_service_client.get_secret_value.side_effect = [ + mock_pending_secret, + mock_current_secret + ] + mock_service_client.describe_secret.return_value = mock_metadata + + rotator.get_deployed_distributions = MagicMock() + rotator.get_deployed_distributions.return_value = mock_distributions + + rotator.run_test_origin_access = MagicMock() + rotator.run_test_origin_access.return_value = True + + rotator.run_test_secret(mock_service_client, "test-arn", "test_token") + + expected_test_calls = [ + call("http://domain1.example.com", "new-secret"), + call("http://domain1.example.com", "current-secret"), + call("http://domain2.example.com", "new-secret"), + call("http://domain2.example.com", "current-secret") + ] + rotator.run_test_origin_access.assert_has_calls(expected_test_calls, any_order=True) + + + +class TestFinishSecretStage: + """ + Test final_secret stage moves the AWSPENDING secret to AWSCURRENT. + """ + + def test_finish_secret_completes_rotation(self, rotator): + """ + finish_secret must properly complete the rotation by: + 1. Moving AWSPENDING to AWSCURRENT + 2. Removing AWSCURRENT from old version + """ + mock_service_client = MagicMock() + mock_service_client.describe_secret.return_value = { + "VersionIdsToStages": { + "old-version": ["AWSCURRENT"], + "test-token": ["AWSPENDING"] + } + } + + rotator.finish_secret( + mock_service_client, + "test-arn", + "test-token" + ) + + mock_service_client.update_secret_version_stage.assert_called_once_with( + SecretId="test-arn", + VersionStage="AWSCURRENT", + MoveToVersionId="test-token", + RemoveFromVersionId="old-version" + ) + + def test_finish_secret_handles_no_previous_version(self, rotator): + """ + When no AWSCURRENT version exists (first rotation), + finish_secret should still complete successfully + """ + mock_service_client = MagicMock() + mock_service_client.describe_secret.return_value = { + "VersionIdsToStages": { + "test-token": ["AWSPENDING"] + } + } + + rotator.finish_secret( + mock_service_client, + "test-arn", + "test-token" + ) + + mock_service_client.update_secret_version_stage.assert_called_once_with( + SecretId="test-arn", + VersionStage="AWSCURRENT", + MoveToVersionId="test-token", + RemoveFromVersionId=None + ) + + def test_finish_secret_handles_api_errors(self, rotator): + """ + finish_secret must handle AWS API errors gracefully + """ + mock_service_client = MagicMock() + mock_service_client.describe_secret.side_effect = ClientError( + {"Error": {"Code": "ResourceNotFoundException"}}, + "describe_secret" + ) + + with pytest.raises(ClientError) as exc_info: + rotator.finish_secret( + mock_service_client, + "test-arn", + "test-token" + ) + + assert exc_info.value.response["Error"]["Code"] == "ResourceNotFoundException" + mock_service_client.update_secret_version_stage.assert_not_called() + +class TestErrorHandling: + """ + Tests for error handling throughout the rotation process. + """ + + def test_fails_early_if_distribution_not_deployed(self, rotator): + """ + If any distribution is not in 'Deployed' state, the entire rotation must fail + before making any changes + """ + mock_distributions = [ + {"Id": "DIST1", "Origin": "origin1.example.com"}, + {"Id": "DIST2", "Origin": "origin2.example.com"} + ] + + mock_service_client = MagicMock() + + rotator.get_deployed_distributions = MagicMock() + rotator.get_deployed_distributions.return_value = mock_distributions + + rotator.is_distribution_deployed = MagicMock(side_effect=lambda distro_id: distro_id == "DIST1") + + rotator.update_waf_acl = MagicMock() + + with pytest.raises(ValueError) as exc_info: + rotator.set_secret(mock_service_client, "test-arn", "test_token") + + assert "status is not Deployed" in str(exc_info.value) + rotator.update_waf_acl.assert_not_called() + + + def test_handles_waf_update_failure_without_distribution_updates(self, rotator): + """ + If WAF update fails, no distribution updates should occur. + """ + mock_distributions = [{"Id": "DIST1", "Origin": "origin1.example.com"}] + mock_get_distro = {"Distribution": {"Status": "Deployed"}} + + mock_pending_secret = {"SecretString": json.dumps({"HEADERVALUE": "AWSPENDING"})} + mock_current_secret = {"SecretString": json.dumps({"HEADERVALUE": "AWSCURRENT"})} + + mock_service_client = MagicMock() + mock_service_client.describe_secret.return_value = { + "VersionIdsToStages": mock_current_secret + } + + mock_service_client.get_secret_value.side_effect = [ + mock_pending_secret, # For AWSPENDING + mock_current_secret, # For AWSCURRENT + ] + + rotator.get_deployed_distributions = MagicMock() + rotator.get_deployed_distributions.return_value = mock_distributions + + rotator.get_cf_distro = MagicMock() + rotator.get_cf_distro.return_value = mock_get_distro + + rotator.update_cf_distro = MagicMock() + rotator.process_cf_distributions_and_WAF_rules = MagicMock() + + rotator.process_cf_distributions_and_WAF_rules.side_effect = ClientError( + {"Error": {"Code": "WAFInvalidParameterException"} }, "process_cf_distributions_and_WAF_rules" ) + + with pytest.raises(ValueError): + rotator.set_secret(mock_service_client, "test-arn", "test_token") + + # Ensure no CloudFront distribution updates occur + rotator.update_cf_distro.assert_not_called() + + +class TestEdgeCases: + + def test_handles_empty_distribution_list_gracefully(self, rotator): + """ + When no matching distributions are found: + 1. WAF rules should not be updated. + 2. Distribution updates should not be attempted. + 3. The method should raise an error and stop. + """ + mock_pending_secret = {"SecretString": json.dumps({"HEADERVALUE": "new-secret"})} + mock_current_secret = {"SecretString": json.dumps({"HEADERVALUE": "current-secret"})} + mock_metadata = { + "VersionIdsToStages": { + "current-version": ["AWSCURRENT"], + "test-token": ["AWSPENDING"], + } + } + mock_credentials = { + "Credentials": { + "AccessKeyId": "test-access-key", + "SecretAccessKey": "test-secret-key", + "SessionToken": "test-session-token" + } + } + + rotator.get_deployed_distributions = MagicMock() + rotator.get_deployed_distributions.return_value = [] + + mock_boto_client = MagicMock() + mock_boto_client.assume_role.return_value = mock_credentials + mock_boto_client.get_secret_value.side_effect = [ + mock_pending_secret, # For AWSPENDING + mock_current_secret, # For AWSCURRENT + ] + mock_boto_client.describe_secret.return_value = mock_metadata + + rotator.get_waf_acl = MagicMock() + rotator.update_cf_distro = MagicMock() + time.sleep = MagicMock() + + with pytest.raises(ValueError, match="No matching distributions found. Cannot update Cloudfront distributions or WAF ACLs"): + rotator.set_secret(mock_boto_client, "test-arn", "token") + + rotator.get_waf_acl.assert_not_called() + rotator.update_cf_distro.assert_not_called() + time.sleep.assert_not_called() + + def test_handles_malformed_secret_data(self, rotator): + mock_service_client = MagicMock() + mock_service_client.get_secret_value.return_value = { + "SecretString": "invalid-json" + } + + with pytest.raises(ValueError): + rotator.run_test_secret( + mock_service_client, + "test-arn", + "test-token" + ) + +class TestLambdaHandler: + + def test_executes_correct_rotation_step(self): + """ + Lambda must execute the correct rotation step based on the event. + """ + event = { + "SecretId": "test-arn", + "ClientRequestToken": "test-token", + "Step": "createSecret" + } + + mock_rotator = MagicMock() + mock_boto_client = MagicMock() + + with patch("boto3.client", return_value=mock_boto_client): + with patch("rotate_secret_lambda.SecretRotator", return_value=mock_rotator): + + from rotate_secret_lambda import lambda_handler + lambda_handler(event, None) + + mock_rotator.create_secret.assert_called_once_with( + mock_boto_client, "test-arn", "test-token" + ) + + + def test_run_test_secret_with_test_domains(self, rotator): + """ + Tests the testSecret step in the event. + """ + event = { + "SecretId": "test-arn", + "ClientRequestToken": "test-token", + "Step": "testSecret", + "TestDomains": ["domain1.example.com", "domain2.example.com"] + } + mock_distributions = [ + {"Id": "DIST1", "Origin": "domain1.example.com"}, + {"Id": "DIST2", "Origin": "domain2.example.com"} + ] + + mock_pending_secret = { + "SecretString": json.dumps({"HEADERVALUE": "new-secret"}) + } + + mock_current_secret = { + "SecretString": json.dumps({"HEADERVALUE": "current-secret"}) + } + + mock_metadata = { + "RotationEnabled": True, + "VersionIdsToStages": { + "current-token": ["AWSCURRENT"], + "test-token": ["AWSPENDING"] + } + } + + mock_boto_client = MagicMock() + mock_boto_client.describe_secret.return_value = mock_metadata + mock_boto_client.get_secret_value.side_effect = [ + mock_pending_secret, + mock_current_secret + ] + + rotator.get_deployed_distributions = MagicMock() + rotator.get_deployed_distributions.return_value = mock_distributions + + with patch('boto3.client') as mock_boto_client, \ + patch('rotate_secret_lambda.SecretRotator') as mock_rotator: + + mock_rotator_instance = mock_rotator.return_value + + from rotate_secret_lambda import lambda_handler + lambda_handler(event, None) + + actual_calls = mock_rotator_instance.run_test_secret.call_args_list + + assert len(actual_calls) == 1, f"Expected run_test_secret to be called once, but it was called {mock_rotator_instance.run_test_secret.call_count} times." + + call_args = actual_calls[0][0] + for i, arg in enumerate(call_args): + assert call_args[1] == "test-arn" + assert call_args[2] == "test-token" + assert call_args[3] == ['domain1.example.com', 'domain2.example.com'] + + + + + def test_run_test_secret_triggers_slack_message(self, rotator): + """ + Tests the testSecret step with a TestDomains property in the event. + Verifies that slack notifications are triggered for test failures. + """ + test_domains = [ + "invalidservice1.environment.testapp.domain.digital", + "invalidservice2.environment.testapp.domain.digital" + ] + + mock_slack_instance = MagicMock() + rotator.slack_service = mock_slack_instance + + rotator.run_test_secret( + service_client=MagicMock(), + arn="test-arn", + token="test-token", + test_domains=test_domains + ) + + expected_failures = [ + { + 'domain': 'invalidservice1.environment.testapp.domain.digital', + 'error': 'Simulating test failure for domain: http://invalidservice1.environment.testapp.domain.digital' + }, + { + 'domain': 'invalidservice2.environment.testapp.domain.digital', + 'error': 'Simulating test failure for domain: http://invalidservice2.environment.testapp.domain.digital' + } + ] + + mock_slack_instance.send_test_failures.assert_called_once_with( + failures=expected_failures, + environment=rotator.environment, + application=rotator.application + ) diff --git a/application-load-balancer/locals.tf b/application-load-balancer/locals.tf index 4387d5ebf..2abf28008 100644 --- a/application-load-balancer/locals.tf +++ b/application-load-balancer/locals.tf @@ -39,4 +39,7 @@ locals { # Count total number of domains. number_of_domains = length(local.full_list) + domain_list = lookup(var.config, "cdn_domains_list", null) != null ? join(",", keys(var.config.cdn_domains_list)) : "" + + config_with_defaults = { slack_alert_channel_alb_secret_rotation = coalesce(try(var.config.slack_alert_channel_alb_secret_rotation, null), "C31KW7NLE") } # Slack ID for P2 alerts channel } diff --git a/application-load-balancer/main.tf b/application-load-balancer/main.tf index ecba5da87..3690ca077 100644 --- a/application-load-balancer/main.tf +++ b/application-load-balancer/main.tf @@ -1,3 +1,7 @@ +data "aws_ssm_parameter" "slack_token" { + name = "/codebuild/slack_oauth_token" +} + data "aws_vpc" "vpc" { filter { name = "tag:Name" @@ -166,3 +170,266 @@ output "cert-arn" { output "alb-arn" { value = aws_lb.this.arn } + + +## This section configures WAF on ALB to attach security token. + +data "aws_caller_identity" "current" {} + +# Random password for the secret value +resource "random_password" "origin-secret" { + length = 32 + special = false + override_special = "_%@" +} + +resource "aws_wafv2_web_acl" "waf-acl" { + # checkov:skip=CKV2_AWS_31: Ensure WAF2 has a Logging Configuration to be done new ticket + # checkov:skip=CKV_AWS_192: AWSManagedRulesKnownBadInputsRuleSet handles on the CDN + depends_on = [random_password.origin-secret] + + name = "${var.application}-${var.environment}-ACL" + description = "CloudFront Origin Verify" + scope = "REGIONAL" + + default_action { + block {} # Action to perform if none of the rules contained in the WebACL match + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${var.application}-${var.environment}-XOriginVerify" + sampled_requests_enabled = true + } + + rule { + name = "${var.application}-${var.environment}-XOriginVerify" + priority = "0" + + action { + allow {} + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${var.application}-${var.environment}-XMetric" + sampled_requests_enabled = true + } + + # This is just a holding rule, it needs to initially not block any traffic. + statement { + not_statement { + statement { + byte_match_statement { + field_to_match { + single_header { + name = "x-origin-verify" + } + } + positional_constraint = "EXACTLY" + search_string = "initial" + text_transformation { + priority = 0 + type = "NONE" + } + } + } + } + } + } + + lifecycle { + # Use `ignore_changes` to allow rotation without Terraform overwriting the value + ignore_changes = [rule] + } + tags = local.tags + +} + +# AWS Lambda Resources + +# IAM Role for Lambda Execution +resource "aws_iam_role" "origin-secret-rotate-execution-role" { + name = "${var.application}-${var.environment}-origin-secret-rotate-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { Service = "lambda.amazonaws.com" } + Action = "sts:AssumeRole" + }] + }) + + tags = local.tags +} + +data "aws_iam_policy_document" "origin_verify_rotate_policy" { + statement { + effect = "Allow" + actions = [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents", + "logs:DescribeLogStreams" + ] + resources = [ + "arn:aws:logs:eu-west-2:${data.aws_caller_identity.current.account_id}:log-group:/aws/lambda/*origin-secret-rotate*" + ] + } + + statement { + effect = "Allow" + actions = [ + "secretsmanager:DescribeSecret", + "secretsmanager:GetSecretValue", + "secretsmanager:PutSecretValue", + "secretsmanager:UpdateSecretVersionStage" + ] + resources = [ + "arn:aws:secretsmanager:eu-west-2:${data.aws_caller_identity.current.account_id}:secret:${var.application}-${var.environment}-origin-verify-header-secret-*" + ] + } + + statement { + effect = "Allow" + actions = ["secretsmanager:GetRandomPassword"] + resources = ["*"] + } + + statement { + effect = "Allow" + actions = [ + "cloudfront:GetDistribution", + "cloudfront:GetDistributionConfig", + "cloudfront:ListDistributions", + "cloudfront:UpdateDistribution" + ] + resources = [ + "arn:aws:cloudfront::${var.dns_account_id}:distribution/*" + ] + } + + statement { + effect = "Allow" + actions = ["wafv2:*"] + resources = [ + aws_wafv2_web_acl.waf-acl.arn + ] + } + + statement { + effect = "Allow" + actions = ["wafv2:UpdateWebACL"] + resources = [ + "arn:aws:wafv2:eu-west-2:${data.aws_caller_identity.current.account_id}:regional/managedruleset/*/*" + ] + } + + statement { + effect = "Allow" + actions = ["sts:AssumeRole"] + resources = [ + "arn:aws:iam::${var.dns_account_id}:role/dbt_platform_cloudfront_token_rotation" + ] + } + + statement { + effect = "Allow" + actions = [ + "kms:Decrypt", + "kms:DescribeKey", + "kms:Encrypt", + "kms:GenerateDataKey" + ] + resources = [ + aws_kms_key.origin_verify_secret_key.arn + ] + } +} + +resource "aws_iam_role_policy" "origin_secret_rotate_policy" { + name = "OriginVerifyRotatePolicy" + role = aws_iam_role.origin-secret-rotate-execution-role.name + policy = data.aws_iam_policy_document.origin_verify_rotate_policy.json +} + + + +# This file needs to exist, but it's not directly used in the Terraform so... +# tflint-ignore: terraform_unused_declarations +# This resource creates the Lambda function code zip file +data "archive_file" "lambda" { + type = "zip" + source_dir = "${path.module}/lambda_function" + output_path = "${path.module}/lambda_function.zip" # This zip contains only your function code + excludes = [ + "**/.DS_Store", + "**/.idea/*" + ] + + depends_on = [ + aws_iam_role.origin-secret-rotate-execution-role + ] +} + + +# Secrets Manager Rotation Lambda Function +resource "aws_lambda_function" "origin-secret-rotate-function" { + # Precedence in the Postgres Lambda to skip first 2 checks + # checkov:skip=CKV_AWS_272:Code signing is not currently in use + # checkov:skip=CKV_AWS_116:Dead letter queue not required due to the nature of this function + # checkov:skip=CKV_AWS_173:Encryption of environmental variables is not configured with KMS key + # checkov:skip=CKV_AWS_117:Run Lambda inside VPC with security groups & private subnets not necessary + # checkov:skip=CKV_AWS_50:XRAY tracing not used + depends_on = [data.archive_file.lambda, aws_iam_role.origin-secret-rotate-execution-role] + filename = data.archive_file.lambda.output_path + function_name = "${var.application}-${var.environment}-origin-secret-rotate" + description = "Secrets Manager Rotation Lambda Function" + handler = "rotate_secret_lambda.lambda_handler" + runtime = "python3.9" + timeout = 300 + role = aws_iam_role.origin-secret-rotate-execution-role.arn + # this is not a user-facing function that needs to scale rapidly + reserved_concurrent_executions = 5 + + environment { + variables = { + SECRETID = aws_secretsmanager_secret.origin-verify-secret.arn + WAFACLID = aws_wafv2_web_acl.waf-acl.id + # todo: why are we splitting on |, should it just be aws_wafv2_web_acl.waf-acl.name? + WAFACLNAME = split("|", aws_wafv2_web_acl.waf-acl.name)[0] + WAFRULEPRI = "0" + DISTROIDLIST = local.domain_list + HEADERNAME = "x-origin-verify" + APPLICATION = var.application + ENVIRONMENT = var.environment + ROLEARN = "arn:aws:iam::${var.dns_account_id}:role/dbt_platform_cloudfront_token_rotation" + AWS_ACCOUNT = data.aws_caller_identity.current.account_id + SLACK_TOKEN = data.aws_ssm_parameter.slack_token.value + SLACK_CHANNEL = local.config_with_defaults.slack_alert_channel_alb_secret_rotation + WAF_SLEEP_DURATION = "75" + } + } + + layers = ["arn:aws:lambda:eu-west-2:763451185160:layer:python-requests:1"] + source_code_hash = data.archive_file.lambda.output_base64sha256 + tags = local.tags +} + +# Lambda Permission for Secrets Manager Rotation +resource "aws_lambda_permission" "rotate-function-invoke-permission" { + statement_id = "AllowSecretsManagerInvocation" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.origin-secret-rotate-function.function_name + principal = "secretsmanager.amazonaws.com" + + # chekov CKV_AWS_364 requirement: limit lambda invocation by secrets in the same AWS account + source_account = data.aws_caller_identity.current.account_id +} + +# Associate WAF ACL with ALB +resource "aws_wafv2_web_acl_association" "waf-alb-association" { + resource_arn = aws_lb.this.arn + web_acl_arn = aws_wafv2_web_acl.waf-acl.arn +} diff --git a/application-load-balancer/providers.tf b/application-load-balancer/providers.tf index 9a3c77c94..c353cce13 100644 --- a/application-load-balancer/providers.tf +++ b/application-load-balancer/providers.tf @@ -8,5 +8,17 @@ terraform { aws.domain, ] } + random = { + source = "hashicorp/random" + version = "~> 3.6.0" + } + archive = { + source = "hashicorp/archive" + version = "~> 2.4.2" + } + null = { + source = "hashicorp/null" + version = "~> 3.2.3" + } } } diff --git a/application-load-balancer/secret_manager.tf b/application-load-balancer/secret_manager.tf new file mode 100644 index 000000000..952cab3d7 --- /dev/null +++ b/application-load-balancer/secret_manager.tf @@ -0,0 +1,87 @@ + +resource "aws_secretsmanager_secret" "origin-verify-secret" { + name = "${var.application}-${var.environment}-origin-verify-header-secret" + description = "Secret used for Origin verification in WAF rules" + kms_key_id = aws_kms_key.origin_verify_secret_key.key_id + recovery_window_in_days = 0 + tags = local.tags +} + +data "aws_iam_policy_document" "secret_manager_policy" { + statement { + sid = "AllowAssumedRoleToAccessSecret" + effect = "Allow" + + principals { + type = "AWS" + identifiers = [ + "arn:aws:iam::${var.dns_account_id}:role/environment-pipeline-assumed-role" + ] + } + + actions = [ + "secretsmanager:GetSecretValue", + "secretsmanager:DescribeSecret"] + resources = [aws_secretsmanager_secret.origin-verify-secret.arn] + } +} + +resource "aws_secretsmanager_secret_policy" "secret_policy" { + secret_arn = aws_secretsmanager_secret.origin-verify-secret.arn + policy = data.aws_iam_policy_document.secret_manager_policy.json +} + +resource "aws_kms_key" "origin_verify_secret_key" { + description = "KMS key for ${var.application}-${var.environment}-origin-verify-header-secret" + deletion_window_in_days = 10 + enable_key_rotation = true + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "Enable IAM User Permissions" + Effect = "Allow" + Principal = { + AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root" + + } + Action = "kms:*" + Resource = "*" + }, + { + Sid = "Allow Rotation Lambda Function to Use Key" + Effect = "Allow" + Principal = { + AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/${var.application}-${var.environment}-origin-secret-rotate-role" + } + Action = ["kms:Decrypt", "kms:Encrypt", "kms:GenerateDataKey"] + Resource = "*" + } + ] + }) + + tags = local.tags + depends_on = [aws_iam_role.origin-secret-rotate-execution-role] +} + +resource "aws_kms_alias" "origin_verify_secret_key_alias" { + name = "alias/${var.application}-${var.environment}-origin-verify-header-secret-key" + target_key_id = aws_kms_key.origin_verify_secret_key.key_id +} + +# Secrets Manager Rotation Schedule +resource "aws_secretsmanager_secret_rotation" "origin-verify-rotate-schedule" { + secret_id = aws_secretsmanager_secret.origin-verify-secret.id + rotation_lambda_arn = aws_lambda_function.origin-secret-rotate-function.arn + rotate_immediately = true + rotation_rules { + automatically_after_days = 7 + } +} + +# Output used in CDN module +output "origin_verify_secret_id" { + value = aws_secretsmanager_secret.origin-verify-secret.id + description = "The secret ID for origin verification header." +} diff --git a/application-load-balancer/tests/unit.tftest.hcl b/application-load-balancer/tests/unit.tftest.hcl index fda72b9a6..3609478bd 100644 --- a/application-load-balancer/tests/unit.tftest.hcl +++ b/application-load-balancer/tests/unit.tftest.hcl @@ -1,4 +1,12 @@ -mock_provider "aws" {} +mock_provider "aws" { + mock_data "aws_caller_identity" { + defaults = { + account_id = "123456789012" + id = "123456789012" + user_id = "XXXXXXXXXXXXXXXXXXXXX" } + } + +} mock_provider "aws" { alias = "sandbox" @@ -6,6 +14,12 @@ mock_provider "aws" { mock_provider "aws" { alias = "domain" + mock_data "aws_caller_identity" { + defaults = { + account_id = "123456789012" + id = "123456789012" + user_id = "XXXXXXXXXXXXXXXXXXXXX" } + } } override_data { @@ -36,17 +50,27 @@ override_data { } } +override_data { + target = data.aws_iam_policy_document.origin_verify_rotate_policy + values = { + json = "{\"Sid\": \"LambdaExecutionRolePolicy\"}" + } +} + variables { - application = "app" - environment = "env" - vpc_name = "vpc-name" + application = "app" + environment = "env" + vpc_name = "vpc-name" + dns_account_id = "123456789012" + cloudfront_id = ["123456789"] config = { domain_prefix = "dom-prefix", cdn_domains_list = { "web.dev.my-application.uktrade.digital" : ["internal.web", "my-application.uktrade.digital"] "api.dev.my-application.uktrade.digital" : ["internal.api", "my-application.uktrade.digital"] } + slack_alert_channel_alb_secret_rotation = "/slack/test/ssm/parameter/name" } } @@ -261,8 +285,9 @@ run "domain_length_validation_tests" { application = "app" environment = "env" config = { - domain_prefix = "dom-prefix", - cdn_domains_list = { "a-very-long-domain-name-used-to-test-length-validation.my-application.uktrade.digital" : ["internal", "my-application.uktrade.digital"] } + domain_prefix = "dom-prefix", + cdn_domains_list = { "a-very-long-domain-name-used-to-test-length-validation.my-application.uktrade.digital" : ["internal", "my-application.uktrade.digital"] } + slack_alert_channel_alb_secret_rotation = "/slack/test/ssm/parameter/name" } } @@ -271,17 +296,247 @@ run "domain_length_validation_tests" { ] } -run "domain_length_validation_tests_succeed_with_empty_config" { +run "domain_length_validation_tests_succeed_with_empty_cdn_domains_list_in_config" { command = plan variables { application = "app" environment = "env" - config = {} + config = { + slack_alert_channel_alb_secret_rotation = "/slack/test/ssm/parameter/name" + } } assert { condition = var.config.cdn_domains_list == null error_message = "Should be: null" } + + assert { + condition = local.domain_list == "" + error_message = "Should be: \"\"" + } +} + +run "waf_and_rotate_lambda" { + command = plan + + assert { + condition = aws_secretsmanager_secret.origin-verify-secret.name == "${var.application}-${var.environment}-origin-verify-header-secret" + error_message = "Invalid name for aws_secretsmanager_secret.origin-verify-secret" + } + + assert { + condition = aws_secretsmanager_secret.origin-verify-secret.description == "Secret used for Origin verification in WAF rules" + error_message = "Invalid description for aws_secretsmanager_secret.origin-verify-secret" + } + + assert { + condition = aws_wafv2_web_acl.waf-acl.name == "${var.application}-${var.environment}-ACL" + error_message = "Invalid name for aws_wafv2_web_acl.waf-acl" + } + + assert { + condition = aws_wafv2_web_acl.waf-acl.description == "CloudFront Origin Verify" + error_message = "Invalid description for aws_wafv2_web_acl.waf-acl" + } + + assert { + condition = aws_wafv2_web_acl.waf-acl.scope == "REGIONAL" + error_message = "Invalid scope for aws_wafv2_web_acl.waf-acl" + } + + assert { + condition = aws_wafv2_web_acl.waf-acl.default_action[0].block != null + error_message = "Invalid default_action for aws_wafv2_web_acl.waf-acl" + } + + assert { + condition = aws_wafv2_web_acl.waf-acl.visibility_config[0].cloudwatch_metrics_enabled == true + error_message = "Invalid visibility_config for aws_wafv2_web_acl.waf-acl" + } + + assert { + condition = aws_wafv2_web_acl.waf-acl.visibility_config[0].metric_name == "${var.application}-${var.environment}-XOriginVerify" + error_message = "Invalid metric_name in visibility_config for aws_wafv2_web_acl.waf-acl" + } + + assert { + condition = aws_wafv2_web_acl.waf-acl.visibility_config[0].sampled_requests_enabled == true + error_message = "Invalid sampled_requests_enabled in visibility_config for aws_wafv2_web_acl.waf-acl" + } + + assert { + condition = length([for r in aws_wafv2_web_acl.waf-acl.rule : r.name if r.name == "${var.application}-${var.environment}-XOriginVerify"]) == 1 + error_message = "Invalid rule name for aws_wafv2_web_acl.waf-acl" + } + + assert { + condition = [for r in aws_wafv2_web_acl.waf-acl.rule : r.priority if r.name == "${var.application}-${var.environment}-XOriginVerify"][0] == 0 + error_message = "Invalid priority for rule ${var.application}-${var.environment}-XOriginVerify in aws_wafv2_web_acl.waf-acl" + } + + assert { + condition = length([for r in aws_wafv2_web_acl.waf-acl.rule : try(r.action[0].allow, null) if r.name == "${var.application}-${var.environment}-XOriginVerify" && try(r.action[0].allow, null) != null]) == 1 + error_message = "Invalid rule action for aws_wafv2_web_acl.waf-acl" + } + + assert { + condition = length([for r in aws_wafv2_web_acl.waf-acl.rule : r.visibility_config[0] if r.name == "${var.application}-${var.environment}-XOriginVerify" && r.visibility_config[0].cloudwatch_metrics_enabled == true]) == 1 + error_message = "Invalid visibility_config for aws_wafv2_web_acl.waf-acl rule" + } + + assert { + condition = length([for r in aws_wafv2_web_acl.waf-acl.rule : r.visibility_config[0] if r.name == "${var.application}-${var.environment}-XOriginVerify" && r.visibility_config[0].metric_name == "${var.application}-${var.environment}-XMetric"]) == 1 + error_message = "Invalid metric_name in visibility_config for aws_wafv2_web_acl.waf-acl rule" + } + + assert { + condition = length([for r in aws_wafv2_web_acl.waf-acl.rule : r.visibility_config[0] if r.name == "${var.application}-${var.environment}-XOriginVerify" && r.visibility_config[0].sampled_requests_enabled == true]) == 1 + error_message = "Invalid sampled_requests_enabled in visibility_config for aws_wafv2_web_acl.waf-acl rule" + } + + # --- Testing of the WAF rule statement --- + + assert { + condition = length( + [for r in aws_wafv2_web_acl.waf-acl.rule : + r.name if can(regex("${var.application}-${var.environment}-XOriginVerify", r.name)) + ] + ) > 0 + error_message = "The rule named ${var.application}-${var.environment}-XOriginVerify does not exist in aws_wafv2_web_acl.waf-acl" + } + + assert { + condition = alltrue([ + for r in aws_wafv2_web_acl.waf-acl.rule : + r.name == "${var.application}-${var.environment}-XOriginVerify" ? ( + try(r.statement[0].not_statement[0].statement[0].byte_match_statement[0].field_to_match[0].single_header[0].name, "") == "x-origin-verify" + ) : true + ]) + error_message = "Statement's single header name is incorrect" + } + + assert { + condition = alltrue([ + for r in aws_wafv2_web_acl.waf-acl.rule : + r.name == "${var.application}-${var.environment}-XOriginVerify" ? ( + try(r.statement[0].not_statement[0].statement[0].byte_match_statement[0].positional_constraint, "") == "EXACTLY" + ) : true + ]) + error_message = "First statement positional_constraint should be 'EXACTLY'" + } + + # --- End testing of the WAF rule statement --- + + assert { + condition = aws_lambda_function.origin-secret-rotate-function.function_name == "${var.application}-${var.environment}-origin-secret-rotate" + error_message = "Invalid name for aws_lambda_function.origin-secret-rotate-function" + } + + assert { + condition = aws_lambda_function.origin-secret-rotate-function.description == "Secrets Manager Rotation Lambda Function" + error_message = "Invalid description for aws_lambda_function.origin-secret-rotate-function" + } + + assert { + condition = aws_lambda_function.origin-secret-rotate-function.handler == "rotate_secret_lambda.lambda_handler" + error_message = "Invalid handler for aws_lambda_function.origin-secret-rotate-function" + } + + assert { + condition = aws_lambda_function.origin-secret-rotate-function.runtime == "python3.9" + error_message = "Invalid runtime for aws_lambda_function.origin-secret-rotate-function" + } + + assert { + condition = aws_lambda_function.origin-secret-rotate-function.timeout == 300 + error_message = "Invalid timeout for aws_lambda_function.origin-secret-rotate-function" + } + + + assert { + condition = aws_lambda_function.origin-secret-rotate-function.environment[0].variables.WAFACLNAME == split("|", aws_wafv2_web_acl.waf-acl.name)[0] + error_message = "Invalid WAFACLNAME environment variable for aws_lambda_function.origin-secret-rotate-function" + } + + assert { + condition = aws_lambda_function.origin-secret-rotate-function.environment[0].variables.WAFRULEPRI == "0" + error_message = "Invalid WAFRULEPRI environment variable for aws_lambda_function.origin-secret-rotate-function" + } + + assert { + condition = aws_lambda_function.origin-secret-rotate-function.environment[0].variables.HEADERNAME == "x-origin-verify" + error_message = "Invalid HEADERNAME environment variable for aws_lambda_function.origin-secret-rotate-function" + } + + assert { + condition = aws_lambda_function.origin-secret-rotate-function.environment[0].variables.APPLICATION == var.application + error_message = "Invalid APPLICATION environment variable for aws_lambda_function.origin-secret-rotate-function" + } + + assert { + condition = aws_lambda_function.origin-secret-rotate-function.environment[0].variables.ENVIRONMENT == var.environment + error_message = "Invalid ENVIRONMENT environment variable for aws_lambda_function.origin-secret-rotate-function" + } + + assert { + condition = aws_lambda_function.origin-secret-rotate-function.environment[0].variables.ROLEARN == "arn:aws:iam::${var.dns_account_id}:role/dbt_platform_cloudfront_token_rotation" + error_message = "Invalid ROLEARN environment variable for aws_lambda_function.origin-secret-rotate-function" + } + + assert { + condition = aws_lambda_function.origin-secret-rotate-function.environment[0].variables.AWS_ACCOUNT == data.aws_caller_identity.current.account_id + error_message = "Invalid AWS_ACCOUNT environment variable for aws_lambda_function.origin-secret-rotate-function" + } + + assert { + condition = aws_lambda_function.origin-secret-rotate-function.environment[0].variables.SLACK_TOKEN == data.aws_ssm_parameter.slack_token.value + error_message = "Invalid SLACK_TOKEN environment variable for aws_lambda_function.origin-secret-rotate-function" + } + + assert { + condition = aws_lambda_function.origin-secret-rotate-function.environment[0].variables.SLACK_CHANNEL == local.config_with_defaults.slack_alert_channel_alb_secret_rotation + error_message = "Invalid SLACK_CHANNEL environment variable for aws_lambda_function.origin-secret-rotate-function" + } + + assert { + condition = aws_lambda_permission.rotate-function-invoke-permission.statement_id == "AllowSecretsManagerInvocation" + error_message = "Invalid statement_id for aws_lambda_permission.rotate-function-invoke-permission" + } + + assert { + condition = aws_lambda_permission.rotate-function-invoke-permission.action == "lambda:InvokeFunction" + error_message = "Invalid action for aws_lambda_permission.rotate-function-invoke-permission" + } + + assert { + condition = aws_lambda_permission.rotate-function-invoke-permission.function_name == aws_lambda_function.origin-secret-rotate-function.function_name + error_message = "Invalid function_name for aws_lambda_permission.rotate-function-invoke-permission" + } + + assert { + condition = aws_lambda_permission.rotate-function-invoke-permission.principal == "secretsmanager.amazonaws.com" + error_message = "Invalid principal for aws_lambda_permission.rotate-function-invoke-permission" + } + + + assert { + condition = aws_iam_role.origin-secret-rotate-execution-role.name == "${var.application}-${var.environment}-origin-secret-rotate-role" + error_message = "Invalid name for aws_iam_role.origin-secret-rotate-execution-role" + } + + assert { + condition = aws_iam_role.origin-secret-rotate-execution-role.assume_role_policy != null + error_message = "Invalid assume_role_policy for aws_iam_role.origin-secret-rotate-execution-role" + } + + # Cannot assert against the arn in a plan. Requires an apply to evaluate. + + + assert { + condition = aws_secretsmanager_secret_rotation.origin-verify-rotate-schedule.rotation_rules[0].automatically_after_days == 7 + error_message = "Invalid rotation_rules.automatically_after_days for aws_secretsmanager_secret_rotation.origin-verify-rotate-schedule" + } + } diff --git a/application-load-balancer/variables.tf b/application-load-balancer/variables.tf index dbd63e45c..e7721167a 100644 --- a/application-load-balancer/variables.tf +++ b/application-load-balancer/variables.tf @@ -10,14 +10,21 @@ variable "vpc_name" { type = string } +variable "dns_account_id" { + type = string +} + variable "config" { type = object({ - domain_prefix = optional(string) - env_root = optional(string) - cdn_domains_list = optional(map(list(string))) - additional_address_list = optional(list(string)) + domain_prefix = optional(string) + env_root = optional(string) + cdn_domains_list = optional(map(list(string))) + additional_address_list = optional(list(string)) + slack_alert_channel_alb_secret_rotation = optional(string) }) + default = {} + validation { condition = var.config.cdn_domains_list == null ? true : alltrue([ for k, v in var.config.cdn_domains_list : ((length(k) <= 63) && (length(k) >= 3)) diff --git a/cdn/main.tf b/cdn/main.tf index 7f3e00599..ac80996fb 100644 --- a/cdn/main.tf +++ b/cdn/main.tf @@ -57,6 +57,10 @@ data "aws_cloudfront_origin_request_policy" "request-policy-name" { depends_on = [aws_cloudfront_origin_request_policy.origin_request_policy] } +data "aws_secretsmanager_secret_version" "origin_verify_secret_version" { + secret_id = var.origin_verify_secret_id +} + resource "aws_cloudfront_distribution" "standard" { # checkov:skip=CKV_AWS_305:This is managed in the application. # checkov:skip=CKV_AWS_310:No fail-over origin required. @@ -85,6 +89,10 @@ resource "aws_cloudfront_distribution" "standard" { origin_ssl_protocols = local.cdn_defaults.origin.custom_origin_config.origin_ssl_protocols origin_read_timeout = local.cdn_defaults.origin.custom_origin_config.cdn_timeout_seconds } + custom_header { + name = "x-origin-verify" + value = jsondecode(data.aws_secretsmanager_secret_version.origin_verify_secret_version.secret_string)["HEADERVALUE"] + } } default_cache_behavior { @@ -155,6 +163,11 @@ resource "aws_cloudfront_distribution" "standard" { } } + lifecycle { + # Use `ignore_changes` to allow custom_header secret rotation without Terraform overwriting the value + ignore_changes = [origin] + } + tags = local.tags } diff --git a/cdn/tests/unit.tftest.hcl b/cdn/tests/unit.tftest.hcl index 7a6fa19d2..7dd223bc8 100644 --- a/cdn/tests/unit.tftest.hcl +++ b/cdn/tests/unit.tftest.hcl @@ -6,6 +6,10 @@ mock_provider "aws" { alias = "domain" } +mock_provider "aws" { + +} + override_data { target = data.aws_route53_zone.domain-root values = { @@ -14,6 +18,13 @@ override_data { } } +override_data { + target = data.aws_secretsmanager_secret_version.origin_verify_secret_version + values = { + secret_string = "{\"HEADERVALUE\": \"some-secret\"}" + } +} + variables { application = "app" environment = "env" @@ -22,6 +33,7 @@ variables { domain_prefix = "dom-prefix", cdn_domains_list = { "dev.my-application.uktrade.digital" : ["internal", "my-application.uktrade.digital"], "dev2.my-application.uktrade.digital" : ["internal", "my-application.uktrade.digital", "disable_cdn"] } } + origin_verify_secret_id = "dummy123" } diff --git a/cdn/variables.tf b/cdn/variables.tf index 526c04b13..186b1ec64 100644 --- a/cdn/variables.tf +++ b/cdn/variables.tf @@ -43,4 +43,11 @@ variable "config" { ]) error_message = "Items in cdn_domains_list should be between 3 and 63 characters long." } + +} + +# Pulled in from output in ALB module +variable "origin_verify_secret_id" { + description = "The ID of the secret used for origin verification" + type = string } diff --git a/elasticache-redis/tests/unit.tftest.hcl b/elasticache-redis/tests/unit.tftest.hcl index ef55a38ed..91483b0fc 100644 --- a/elasticache-redis/tests/unit.tftest.hcl +++ b/elasticache-redis/tests/unit.tftest.hcl @@ -93,6 +93,8 @@ run "aws_elasticache_replication_group_unit_test" { error_message = "Invalid config for aws_elasticache_replication_group transit_encryption_enabled" } + + # Set to a string due to changes in this release: https://github.com/hashicorp/terraform-provider-aws/releases/tag/v5.82.0 If this test fails, run terraform init -upgrade in the module directory assert { condition = aws_elasticache_replication_group.redis.at_rest_encryption_enabled == "true" error_message = "Invalid config for aws_elasticache_replication_group at_rest_encryption_enabled" @@ -173,6 +175,7 @@ run "aws_elasticache_replication_group_unit_test2" { error_message = "Invalid config for aws_elasticache_replication_group transit_encryption_enabled" } + # Set to a string due to changes in this release: https://github.com/hashicorp/terraform-provider-aws/releases/tag/v5.82.0 If this test fails, run terraform init -upgrade in the module directory assert { condition = aws_elasticache_replication_group.redis.at_rest_encryption_enabled == "true" error_message = "Invalid config for aws_elasticache_replication_group at_rest_encryption_enabled" diff --git a/environment-pipelines/iam.tf b/environment-pipelines/iam.tf index 24c6a5517..4d9127ee3 100644 --- a/environment-pipelines/iam.tf +++ b/environment-pipelines/iam.tf @@ -281,7 +281,8 @@ data "aws_iam_policy_document" "load_balancer" { "elasticloadbalancing:ModifyLoadBalancerAttributes", "elasticloadbalancing:DeleteLoadBalancer", "elasticloadbalancing:CreateListener", - "elasticloadbalancing:ModifyListener" + "elasticloadbalancing:ModifyListener", + "elasticloadbalancing:SetWebACL" ] resources = [ "arn:aws:elasticloadbalancing:${local.account_region}:loadbalancer/app/${var.application}-${statement.value.name}/*" @@ -374,7 +375,8 @@ data "aws_iam_policy_document" "ssm_parameter" { resources = [ "arn:aws:ssm:${local.account_region}:parameter/copilot/${var.application}/*/secrets/*", "arn:aws:ssm:${local.account_region}:parameter/copilot/applications/${var.application}", - "arn:aws:ssm:${local.account_region}:parameter/copilot/applications/${var.application}/*" + "arn:aws:ssm:${local.account_region}:parameter/copilot/applications/${var.application}/*", + "arn:aws:ssm:${local.account_region}:parameter/***" ] } } @@ -944,6 +946,7 @@ data "aws_iam_policy_document" "codepipeline" { "codepipeline:GetPipelineExecution", "codepipeline:ListPipelineExecutions", "codepipeline:StopPipelineExecution", + "codepipeline:UpdatePipeline" ] resources = [ "arn:aws:codepipeline:${local.account_region}:${var.application}-${var.pipeline_name}-environment-pipeline" @@ -1064,6 +1067,110 @@ resource "aws_iam_role_policy_attachment" "attach_redis_policy" { policy_arn = aws_iam_policy.redis.arn } +data "aws_iam_policy_document" "lambda_policy_access" { + + dynamic "statement" { + for_each = local.environment_config + content { + sid = "LambdaPolicyAccess" + effect = "Allow" + actions = [ + "lambda:GetPolicy", + "lambda:RemovePermission", + "lambda:DeleteFunction", + "lambda:TagResource", + "lambda:PutFunctionConcurrency", + "lambda:AddPermission" + ] + resources = [ + "arn:aws:lambda:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:function:${var.application}-${statement.value.name}-origin-secret-rotate" + ] + } + } + + statement { + sid = "LambdaLayerAccess" + effect = "Allow" + actions = [ + "lambda:GetLayerVersion" + ] + resources = [ + "arn:aws:lambda:eu-west-2:763451185160:layer:python-requests:1" + ] + } +} + +data "aws_iam_policy_document" "wafv2_read_access" { + statement { + sid = "WAFv2ReadAccess" + effect = "Allow" + actions = [ + "wafv2:GetWebACL", + "wafv2:GetWebACLForResource", + "wafv2:ListTagsForResource", + "wafv2:DeleteWebACL", + "wafv2:CreateWebACL", + "wafv2:TagResource", + "wafv2:AssociateWebACL" + ] + resources = [ + "arn:aws:wafv2:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:regional/webacl/*/*" + ] + } + statement { + sid = "WAFv2RuleSetAccess" + effect = "Allow" + actions = [ + "wafv2:CreateWebACL" + ] + resources = [ + "arn:aws:wafv2:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:regional/managedruleset/*/*" + ] + } +} + +data "aws_iam_policy_document" "secret_manager_read_access" { + dynamic "statement" { + for_each = local.environment_config + content { + sid = "SecretManagerReadAccess" + effect = "Allow" + actions = [ + "secretsmanager:DescribeSecret", + "secretsmanager:GetSecretValue", + "secretsmanager:GetResourcePolicy", + "secretsmanager:DeleteResourcePolicy", + "secretsmanager:CancelRotateSecret", + "secretsmanager:DeleteSecret", + "secretsmanager:CreateSecret", + "secretsmanager:TagResource", + "secretsmanager:PutResourcePolicy", + "secretsmanager:PutSecretValue", + "secretsmanager:RotateSecret" + ] + resources = [ + "arn:aws:secretsmanager:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:secret:${var.application}-${statement.value.name}-origin-verify-header-secret-*" + ] + } + } +} + +data "aws_iam_policy_document" "origin_secret_rotation_role_access" { + dynamic "statement" { + for_each = local.environment_config + content { + sid = "OriginSecretRotationRoleAccess" + effect = "Allow" + actions = [ + "iam:TagRole" + ] + resources = [ + "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/${var.application}-${statement.value.name}-origin-secret-rotate-role" + ] + } + } +} + resource "aws_iam_role_policy_attachment" "attach_postgres_policy" { role = aws_iam_role.environment_pipeline_codebuild.name policy_arn = aws_iam_policy.postgres.arn @@ -1089,8 +1196,31 @@ resource "aws_iam_role_policy_attachment" "attach_ecs_policy" { policy_arn = aws_iam_policy.ecs.arn } - # Inline policies +resource "aws_iam_role_policy" "lambda_policy_access_for_environment_codebuild" { + name = "${var.application}-${var.pipeline_name}-lambda-policy-access-for-environment-codebuild" + role = aws_iam_role.environment_pipeline_codebuild.name + policy = data.aws_iam_policy_document.lambda_policy_access.json +} + +resource "aws_iam_role_policy" "wafv2_read_access_for_environment_codebuild" { + name = "${var.application}-${var.pipeline_name}-waf2-read-access-for-environment-codebuild" + role = aws_iam_role.environment_pipeline_codebuild.name + policy = data.aws_iam_policy_document.wafv2_read_access.json +} + +resource "aws_iam_role_policy" "secret_manager_read_access_for_environment_codebuild" { + name = "${var.application}-${var.pipeline_name}-secret-manager-read-access-for-environment-codebuild" + role = aws_iam_role.environment_pipeline_codebuild.name + policy = data.aws_iam_policy_document.secret_manager_read_access.json +} + +resource "aws_iam_role_policy" "origin_secret_rotation_role_access_for_environment_codebuild" { + name = "${var.application}-${var.pipeline_name}-origin-secret-rotation-role-access-for-environment-codebuild" + role = aws_iam_role.environment_pipeline_codebuild.name + policy = data.aws_iam_policy_document.origin_secret_rotation_role_access.json +} + resource "aws_iam_role_policy" "artifact_store_access_for_environment_codepipeline" { name = "${var.application}-${var.pipeline_name}-artifact-store-access-for-environment-codepipeline" role = aws_iam_role.environment_pipeline_codepipeline.name diff --git a/environment-pipelines/tests/unit.tftest.hcl b/environment-pipelines/tests/unit.tftest.hcl index b95352cec..bbb5b5558 100644 --- a/environment-pipelines/tests/unit.tftest.hcl +++ b/environment-pipelines/tests/unit.tftest.hcl @@ -217,6 +217,34 @@ override_data { } } +override_data { + target = data.aws_iam_policy_document.lambda_policy_access + values = { + json = "{\"Sid\": \"LambdaPolicyAccess\"}" + } +} + +override_data { + target = data.aws_iam_policy_document.wafv2_read_access + values = { + json = "{\"Sid\": \"WAFv2ReadAccess\"}" + } +} + +override_data { + target = data.aws_iam_policy_document.secret_manager_read_access + values = { + json = "{\"Sid\": \"SecretManagerReadAccess\"}" + } +} + +override_data { + target = data.aws_iam_policy_document.origin_secret_rotation_role_access + values = { + json = "{\"Sid\": \"SecretRotationRolePolicy\"}" + } +} + variables { application = "my-app" repository = "my-repository" diff --git a/extensions/main.tf b/extensions/main.tf index 225b0f325..15192a3fb 100644 --- a/extensions/main.tf +++ b/extensions/main.tf @@ -61,9 +61,10 @@ module "alb" { providers = { aws.domain = aws.domain } - application = var.args.application - environment = var.environment - vpc_name = var.vpc_name + application = var.args.application + environment = var.environment + vpc_name = var.vpc_name + dns_account_id = var.args.dns_account_id config = each.value } @@ -78,6 +79,8 @@ module "cdn" { application = var.args.application environment = var.environment + origin_verify_secret_id = one(values(module.alb)).origin_verify_secret_id + config = each.value } diff --git a/poetry.lock b/poetry.lock index 34a37df3d..1df816779 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. [[package]] name = "boto3" @@ -960,6 +960,20 @@ files = [ {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] +[[package]] +name = "slack-sdk" +version = "3.27.1" +description = "The Slack API Platform SDK for Python" +optional = false +python-versions = ">=3.6" +files = [ + {file = "slack_sdk-3.27.1-py2.py3-none-any.whl", hash = "sha256:c108e509160cf1324c5c8b1f47ca52fb5e287021b8caf9f4ec78ad737ab7b1d9"}, + {file = "slack_sdk-3.27.1.tar.gz", hash = "sha256:85d86b34d807c26c8bb33c1569ec0985876f06ae4a2692afba765b7a5490d28c"}, +] + +[package.extras] +optional = ["SQLAlchemy (>=1.4,<3)", "aiodns (>1.0)", "aiohttp (>=3.7.3,<4)", "boto3 (<=2)", "websocket-client (>=1,<2)", "websockets (>=10,<11)", "websockets (>=9.1,<10)"] + [[package]] name = "tomli" version = "2.0.1" @@ -1055,4 +1069,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "d4b95802a488d494556fd9b58e883a14b0dafa16fbcc9f1302f2b08b3a90c2ca" +content-hash = "cf3063df61de7f2095ecac66310f9bd6011e03e69e2724ebb4f71ae6fc6884b1" diff --git a/pyproject.toml b/pyproject.toml index 355a34c88..8ea52bbe2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ package-mode = false python = "^3.9" boto3 = "^1.35.2" psycopg2-binary = "^2.9.9" +slack-sdk = "3.27.1" [tool.poetry.group.dev.dependencies] pre-commit = "^3.7.0"